🦍

Rustでセキュリティsandboxツールを作った

はじめに

ども
最近、仕事場にClaude Codeを導入して、チームのパフォーマンスが爆増して仕事が減ってきたゴリラです。
Claude Codeを導入する際に便利なMCP(serenaやcontext7、playwrightなど)をいくつか入れたんですが、セキュリティちゃんとしないとねという話しになり、それがきっかけでセキュリティsandboxツールのmoriを作った話しです。

https://github.com/skanehira/mori

mori(杜)とは

セキュリティといっても、様々な観点で対策を打つ必要がありますが、今回開発したmoriはsandbox-execeBPFを使ったファイルI/Oとネットワーク制御(ドメインやIPレベル)ができるsandboxツールです。
ネットワークに関しては機能差分(後述する)がありますが、macOSとLinuxで動作します。また設定ファイルもサポートしています。

既存では、以下のようなsandboxツールがありますが、macOSとLinuxでファイルI/OとドメインやIPレベルのネットワーク制御ができるものはなかったため、moriを作りました。

ツール クロスプラットフォーム ネットワーク制御(ドメイン/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の場合はcagesbxと同様にsandbox-execをwrapしています。
sandbox-execに関してはこちらの記事を参照してください。要はmacOSではデフォルトで使えるsandboxツールでmoriはそれを少し使いやすい形にしただけです。

https://blog.syum.ai/entry/2025/04/27/232946

sandbox-execではネットワークをIP単位で制御できないため、moriではmacOSの場合--allow-network-allしか使えないです。
ファイルI/Oの制御は細かくできるのにネットワークも頑張ってほしいところですね。

LinuxではeBPFを使ってネットワークとファイルI/Oを制御しています。
eBPFはカーネル空間で動作する仮想マシンで、そこに用意したプログラムをロードすると、システムコールのイベントをフックできます。
詳細はこちらの記事を参照して下さい。

https://zenn.dev/hidenori3/articles/e1352e8cfeb2af

構成と処理の流れ

moriはCLI、ポリシー、ランタイム、eBPFの4つのレイヤーで構成されています。

全体のアーキテクチャ

処理フローの詳細

macOSの場合

macOSは比較的シンプルな実装になっています。

  1. ポリシーをSandbox Profile Languageに変換
    • ファイルアクセス制御ルールをSandbox Profile Language(SBPL)という専用のポリシー記述言語に変換します
  2. sandbox-execで子プロセスを起動
    • 変換したポリシーをsandbox-execコマンドに渡して、指定したコマンドを実行します
  3. 制約事項
    • sandbox-execの制約により、ネットワーク制御はIP/ドメイン単位でできません
    • そのため--allow-network-allまたは--deny-network-allのいずれかしか使えません

Linuxの場合

Linuxでは、より細かい制御を実現するためにeBPFを使った複雑な実装になっています。
ざっくりですが以下のようになっています。

  1. cgroup作成

    • 子プロセス(引数で指定したコマンド)だけを制御対象にするため、専用のcgroupを作成します
    • cgroupはプロセスをグループ化してリソース制限や制御を行うLinuxカーネルの機能です
  2. eBPFプログラムのロードとアタッチ

    • ネットワーク制御用のconnect4フックをカーネルにロードし、cgroupにアタッチします
      • connect4はcgroupにアタッチすることで、そのcgroupに属するプロセスだけがイベントを受け取ります
    • ファイルI/O制御用のfile_openフック(LSM)をカーネルにロードします
      • file_openはcgroupにアタッチできないため、システム全体のファイルアクセスイベントを受け取り、フック内でcgroup IDをチェックして対象プロセスを判定します
  3. eBPFマップへのデータ登録

    • ドメインをDNSで名前解決したIPアドレスや、制限対象のファイルパスをeBPFマップに登録します
    • eBPFマップはユーザ空間(CLI)とカーネル空間(eBPFプログラム)でデータを共有するための仕組みです
  4. フックによるポリシーチェック

    • ネットワーク制御: connect4フックがTCP接続時に呼び出され、接続先IPがeBPFマップに登録されているかチェックします
    • ファイルI/O制御: file_openフック(LSM: Linux Security Module)がファイルオープン時に呼び出され、パスとアクセスモードをチェックします
  5. DNS定期更新

    • ドメインを指定した場合、DNSに定期的に問い合わせしてIPが変わった場合でもちゃんとネットワーク制御できるようにします
    • バックグラウンドタスクで定期的に名前解決を行い、IPが変更されたらeBPFマップを更新します

詳細を知りたい方はコードを読んでみるか、deepwikiで色々質問してみてください。

https://deepwiki.com/skanehira/mori

実装を通じて学んだこと

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を使っています。

ネットワーク制御の場合、以下のような流れで動作します:

  1. 子プロセス専用のcgroupを作成
  2. 子プロセスをそのcgroupに追加
  3. eBPFのconnect4フックをcgroupにアタッチ
  4. そのcgroupに属するプロセスだけがフックのイベントを受け取る

この方式なら、システム全体ではなく特定のプロセスだけを制御できます。

しかし、ファイル制御のフックfile_openの場合、Linuxカーネルにcgroupへのアタッチ機能が実装されていません。
つまり、file_openフックはシステム全体のファイルアクセスイベントを受け取ってしまい、moriで起動していないプロセスのイベントも受け取ってしまいます。

そのため、フック内で以下のような処理を追加する必要がありました:

  1. イベントを発生させたプロセスのcgroup idを取得
  2. moriで作成したcgroup idと比較
  3. 一致しない場合は処理をスキップ

落とし穴ですが、ワークアラウンドがあってよかったです。

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は以下のように動作します。

  1. ビルド時:構造体のフィールド名だけを指定してコードを書く
  2. 実行時:カーネルが提供する型情報(BTF: BPF Type Format)を使って、実際のoffsetを自動的に計算
  3. 結果:一度ビルドしたバイナリが、異なるカーネルバージョンでそのまま動作

しかし、moriで使っているクレートはCO-REに対応していないため、以下のような手順でoffsetを取得する必要がありました。

  1. ビルド時にユーザのカーネルからvmlinux(カーネルの型情報)を抽出
  2. vmlinuxからstruct fileの定義を含むRustコード(vmlinux.rs)を自動生成
  3. 生成されたコードを使って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