Rustでセキュリティsandboxツールを作った
はじめに
ども
最近、仕事場にClaude Codeを導入して、チームのパフォーマンスが爆増して仕事が減ってきたゴリラです。
Claude Codeを導入する際に便利なMCP(serenaやcontext7、playwrightなど)をいくつか入れたんですが、セキュリティちゃんとしないとねという話しになり、それがきっかけでセキュリティsandboxツールのmori
を作った話しです。
mori(杜)とは
セキュリティといっても、様々な観点で対策を打つ必要がありますが、今回開発したmoriはsandbox-exec
とeBPF
を使ったファイルI/Oとネットワーク制御(ドメインやIPレベル)ができるsandboxツールです。
ネットワークに関しては機能差分(後述する)がありますが、macOSとLinuxで動作します。また設定ファイルもサポートしています。
既存では、以下のようなsandboxツールがありますが、macOSとLinuxでファイルI/OとドメインやIPレベルのネットワーク制御ができるものはなかったため、mori
を作りました。
- https://github.com/syumai/sbx
- https://github.com/Warashi/cage
- https://github.com/containers/bubblewrap
ツール | クロスプラットフォーム | ネットワーク制御(ドメイン/IPレベル) | ファイルI/O制御 | 設定ファイル |
---|---|---|---|---|
sbx | x(macOSのみ) | △(localhostのみ or all allow) | ◯ | x |
cage | ◯ | x | ◯ | x |
bubblewrap | x(Linuxのみ) | x | ◯ | x |
mori | ◯ | △(macOSの場合はall allow or deny) | ◯ | ◯ |
mori(杜)の基本的な使い方
ネットワーク制御は以下のホワイトリスト方式を取っています。
野良のMCPサーバーを使っていると、どこにどんな情報が送られてしまうのか、実装でも追わない限りわからないためです。
# Allow access to example.com
mori --allow-network example.com -- curl https://example.com
# Allow multiple domains
mori --allow-network example.com,github.com -- your-command
# Allow specific IP addresses
mori --allow-network 192.168.1.1 -- your-command
# Allow CIDR ranges
mori --allow-network 10.0.0.0/24 -- your-command
# Allow all network access (both Linux and macOS)
mori --allow-network-all -- your-command
ファイルI/Oの制御は逆で、ブラックリスト方式を取っています。
ホワイトリストにしてしまうとシェルコマンドすら実行できなくなってしまうので、利便性を考慮して守るべきものを選択するようにしました。
# Deny all access (read/write) to a file
mori --deny-file /etc/passwd -- your-command
# Deny read access to specific files
mori --deny-file-read /etc/shadow,/root/.ssh -- your-command
# Deny write access to specific directories
mori --deny-file-write /var/log,/tmp -- your-command
# Combine multiple file restrictions
mori --deny-file /etc/passwd --deny-file-write /var -- your-command
mori(杜)の仕組み
macOSの場合はcage
とsbx
と同様にsandbox-exec
をwrapしています。
sandbox-exec
に関してはこちらの記事を参照してください。要はmacOSではデフォルトで使えるsandboxツールでmori
はそれを少し使いやすい形にしただけです。
sandbox-exec
ではネットワークをIP単位で制御できないため、mori
ではmacOS
の場合--allow-network-all
しか使えないです。
ファイルI/Oの制御は細かくできるのにネットワークも頑張ってほしいところですね。
LinuxではeBPF
を使ってネットワークとファイルI/Oを制御しています。
eBPF
はカーネル空間で動作する仮想マシンで、そこに用意したプログラムをロードすると、システムコールのイベントをフックできます。
詳細はこちらの記事を参照して下さい。
構成と処理の流れ
moriはCLI、ポリシー、ランタイム、eBPFの4つのレイヤーで構成されています。
全体のアーキテクチャ
処理フローの詳細
macOSの場合
macOSは比較的シンプルな実装になっています。
-
ポリシーをSandbox Profile Languageに変換
- ファイルアクセス制御ルールをSandbox Profile Language(SBPL)という専用のポリシー記述言語に変換します
-
sandbox-execで子プロセスを起動
- 変換したポリシーを
sandbox-exec
コマンドに渡して、指定したコマンドを実行します
- 変換したポリシーを
-
制約事項
-
sandbox-exec
の制約により、ネットワーク制御はIP/ドメイン単位でできません - そのため
--allow-network-all
または--deny-network-all
のいずれかしか使えません
-
Linuxの場合
Linuxでは、より細かい制御を実現するためにeBPFを使った複雑な実装になっています。
ざっくりですが以下のようになっています。
-
cgroup作成
- 子プロセス(引数で指定したコマンド)だけを制御対象にするため、専用のcgroupを作成します
- cgroupはプロセスをグループ化してリソース制限や制御を行うLinuxカーネルの機能です
-
eBPFプログラムのロードとアタッチ
- ネットワーク制御用の
connect4
フックをカーネルにロードし、cgroupにアタッチします-
connect4
はcgroupにアタッチすることで、そのcgroupに属するプロセスだけがイベントを受け取ります
-
- ファイルI/O制御用の
file_open
フック(LSM)をカーネルにロードします-
file_open
はcgroupにアタッチできないため、システム全体のファイルアクセスイベントを受け取り、フック内でcgroup IDをチェックして対象プロセスを判定します
-
- ネットワーク制御用の
-
eBPFマップへのデータ登録
- ドメインをDNSで名前解決したIPアドレスや、制限対象のファイルパスをeBPFマップに登録します
- eBPFマップはユーザ空間(CLI)とカーネル空間(eBPFプログラム)でデータを共有するための仕組みです
-
フックによるポリシーチェック
-
ネットワーク制御:
connect4
フックがTCP接続時に呼び出され、接続先IPがeBPFマップに登録されているかチェックします -
ファイルI/O制御:
file_open
フック(LSM: Linux Security Module)がファイルオープン時に呼び出され、パスとアクセスモードをチェックします
-
ネットワーク制御:
-
DNS定期更新
- ドメインを指定した場合、DNSに定期的に問い合わせしてIPが変わった場合でもちゃんとネットワーク制御できるようにします
- バックグラウンドタスクで定期的に名前解決を行い、IPが変更されたらeBPFマップを更新します
詳細を知りたい方はコードを読んでみるか、deepwikiで色々質問してみてください。
実装を通じて学んだこと
1. sandbox-exec はドメインを指定してネットワーク制御できない
これ非常に残念ですが、sandbox-exec
ではドメイン/IPレベルでネットワークの制御ができず、localhostかすべて許可かの2択しかないです。
sandbox-exec
以外で現実的にeBPFと同等のネットワーク制御ができる仕組みは無さそうなので悔やまれます。
なぜsandbox-exec
はファイルI/Oは細かく制御できるのにネットワークの制御はざっくりなのか、もし知っている方がいればぜひ教えて下さい。
2. eBPFの制限が厳しい
eBPF
はカーネル空間で動作するので、カーネルの安全性のため、わりと制約があります。
ざっくり以下の制約の中で実装をする必要があります。
- スタックは最大 512 byte
- HashMapなどを経由してユーザ空間とのデータ共有
- bounded loop(上限回数が静的に解析可能なもの)だけ許可
- 再帰禁止
- ログの出力が難しい
普通のRustとは異なり、これらの制約を常に意識する必要があります。
使っているクレートが機能を持っていないからか、これらの制約はeBPFのプログラムをコンパイル時にはチェックされず、カーネルにロードされる際にverifyされてNGの場合ロードに失敗します。
eBPFプログラムのlintツールが欲しいところですね。
3. cgroupにfile_open
フックをアタッチしてファイルアクセス制御できない
まずcgroupについて簡単に説明します。cgroup (control group) はLinuxカーネルの機能で、プロセスをグループ化してリソース制限や制御を行う仕組みです。
moriでは起動する子プロセス(引数で指定したコマンド)だけを制御対象にするために、このcgroupを使っています。
ネットワーク制御の場合、以下のような流れで動作します:
- 子プロセス専用のcgroupを作成
- 子プロセスをそのcgroupに追加
- eBPFの
connect4
フックをcgroupにアタッチ - そのcgroupに属するプロセスだけがフックのイベントを受け取る
この方式なら、システム全体ではなく特定のプロセスだけを制御できます。
しかし、ファイル制御のフックfile_open
の場合、Linuxカーネルにcgroupへのアタッチ機能が実装されていません。
つまり、file_open
フックはシステム全体のファイルアクセスイベントを受け取ってしまい、moriで起動していないプロセスのイベントも受け取ってしまいます。
そのため、フック内で以下のような処理を追加する必要がありました:
- イベントを発生させたプロセスのcgroup idを取得
- moriで作成したcgroup idと比較
- 一致しない場合は処理をスキップ
落とし穴ですが、ワークアラウンドがあってよかったです。
4. CO-REが使えない
file_open
フックで受け取るファイル構造体の型定義は以下となっていました。
この定義にはf_path
などのフィールドが一切含まれていません。
#[repr(C)]
#[derive(Debug, Copy, Clone)]
pub struct file {
_unused: [u8; 0],
}
実際には、カーネル内部のstruct file
にはf_path
フィールドが存在し、そこにファイルパス情報が格納されています。
しかし、このフィールドにアクセスするには、構造体の先頭アドレスからf_path
までのバイト数(offset)を知る必要があります。
そして面倒なことに、このoffsetはカーネルのバージョンによって異なる場合があるという点です。
この問題を解決する仕組みとして、CO-RE (Compile Once - Run Everywhere) があります。
CO-REは以下のように動作します。
- ビルド時:構造体のフィールド名だけを指定してコードを書く
- 実行時:カーネルが提供する型情報(BTF: BPF Type Format)を使って、実際のoffsetを自動的に計算
- 結果:一度ビルドしたバイナリが、異なるカーネルバージョンでそのまま動作
しかし、moriで使っているクレートはCO-REに対応していないため、以下のような手順でoffsetを取得する必要がありました。
- ビルド時にユーザのカーネルから
vmlinux
(カーネルの型情報)を抽出 -
vmlinux
からstruct file
の定義を含むRustコード(vmlinux.rs
)を自動生成 - 生成されたコードを使って
file.f_path
のoffsetをコンパイル時に計算
この方式の問題点は、ビルドしたバイナリが特定のカーネルバージョンに依存してしまうことです。
つまり、カーネル5.10でビルドしたバイナリは、カーネル6.1では動かない可能性があります。
これがユーザにとって導入の障壁になっていますが、一旦許容せざるを得ない状況です。
今後の展望
moriはまだまだ改善の余地があるので、今後やっていきたいことを書いておきます。
ビルド用のコンテナを用意
カーネルバージョンの依存問題を少しでも緩和するため、ビルド用のコンテナを用意したいなと思っています。
ビルドに必要なツールセット(Rust、bpftool、clangなど)が揃っているコンテナを用意しておけば、ユーザはdocker run
するだけでビルドできるようになります。
DockerコンテナはホストOSのカーネル上で動くので、ビルド時のカーネルバージョンとホストのカーネルバージョンがずれることはないです。
これでDockerさえインストールされていれば導入が簡単にできるので、かなり使いやすくなるんじゃないかなと思います。
XDPを使ったネットワーク制御の実装
現状、ドメインを指定した場合、バックグラウンドで定期的にDNSに問い合わせてIPを更新していますが、これは少し無駄が多いし、タイミングによっては新しいIPがeBPFマップにまだ入っていない状態でネットワーク通信が発生しブロックされてしまう可能性があります。
XDPを使えば、パケットレベルでDNSクエリやレスポンスを見てドメイン名を解析し、リアルタイムでIPアドレスを把握できます。
これにより定期的なDNS問い合わせが不要になり、タイミングの問題も解消できそうです。
XDP (eXpress Data Path) はLinuxカーネルの機能で、ネットワークパケットがカーネル内部に到達する最も早い段階でeBPFプログラムを実行できる仕組みです。
これを使えれば処理がシンプルになり、タイミングの問題もなくなると思います。
子プロセス制御の実装
今のmoriは、起動したコマンドが更に子プロセスをforkできます。
しかし、それをforkさせたくないユースケースもあると思うので、優先度低ですが実装しようと考えてています。
aya-ebpf から libbpf-rs への乗り換え
これが一番大きな変更ですが、CO-REを使ってよりポータビリティのあるバイナリを提供したいと考えています。
libbpf-rs
はCO-REに対応している様なので、一度ビルドしたバイナリが異なるカーネルバージョンでもそのまま動くようになる見込みです。
最後に
mori
はまだ使いやすくはないと思いますが、ひとまず最低限使える状態で公開しました。
ぜひ使ってみてください。
もし要望やこうするといいよといったアドバイスがあればぜひissueをください。
Discussion