seccompによるシステムコールフィルタリング
信頼できないコードに渡すカーネルの窓口を最小化できる。プロセスが呼べるシステムコールを絞り、攻撃面を削る seccomp-bpf の仕組みを原理から解説します。
- 1.seccomp-bpf はプロセスに BPF プログラムを取り付け、システムコール番号・引数・アーキテクチャを見て許可/拒否などを判定する。一度有効化すると解除できず、子プロセスにも継承される。
- 2.アクションは KILL(プロセス殺害)・ERRNO(指定エラーを返す)・TRAP(SIGSYS送出)・TRACE(ptracerへ委譲)・LOG・ALLOW があり、優先順位の高いものが勝つ。
- 3.コンテナや言語ランタイムのサンドボックスで攻撃面を削るのに使われ、USER_NOTIF を使えば判定そのものを別プロセスへ委譲し、ファイルディスクリプタの受け渡しまで仲介できる。
なぜシステムコールを絞るのか
プロセスがカーネルに対してできることは、究極的にはすべて システムコール を通じて行われます。ファイルを開くのも、ネットワークに繋ぐのも、新しいプロセスを生むのも、入口はシステムコール1本です。逆に言えば、ここを絞れば、たとえコードが乗っ取られてもできることの上限をカーネル側で機械的に制限できます。
一般的な Linux には 350 を超えるシステムコールがありますが、ある特定のワークロードが実際に使うのはごく一部です。使わない ptrace や mount、keyctl、未使用の古い呼び出しを残しておくのは、攻撃者に余分な道具を渡すようなものです。seccomp(secure computing mode) は、この攻撃面(attack surface)をプロセス単位で削るための仕組みです。
2つのモード:strict と filter
seccomp には2つのモードがあります。
- SECCOMP_MODE_STRICT:最古のモード。許されるのは
read/write/_exit/sigreturnの4つだけで、それ以外を呼ぶと即座にプロセスが殺されます。極端に厳しく、用途は限られます。 - SECCOMP_MODE_FILTER(seccomp-bpf):BPF プログラムを与え、システムコールごとに何を許し何を拒むかを自分で定義できます。現在「seccomp」と言えばほぼこちらを指します。
filter モードは prctl(PR_SET_SECCOMP, ...) または専用の seccomp() システムコールで有効化します。後者はスレッドグループ全体への同期適用(TSYNC)などの拡張フラグを持ち、現在はこちらが推奨されます。
seccomp フィルタは一方向です。適用後にプロセス自身がそれを解除する手段はありません。これは設計上の安全策で、「いったん権限を落としたら、攻撃者にも元へ戻させない」ことを保証します。さらに、新しいフィルタを後から追加すると既存フィルタと論理積で重なり、制限は緩むことなく積み重なるだけです。fork/exec した子にも継承されます。
seccomp-bpf:何を入力に判定するのか
filter モードの BPF プログラムは、eBPF ではなく古典 BPF(cBPF)です。検証器付きの汎用 eBPF とは別系統で、ループのない単純な命令列だけを許す古い形式が使われます。システムコールが発行されるたび、カーネルはその文脈を seccomp_data という固定構造体にまとめ、これを入力としてフィルタを実行します。フィルタが見られるのは次の4つです。
struct seccomp_data {
int nr; /* システムコール番号 */
__u32 arch; /* アーキテクチャ識別子(AUDIT_ARCH_*) */
__u64 instruction_pointer; /* 呼び出し時のIP */
__u64 args[6]; /* 引数レジスタの値(最大6個) */
};
ここから重要な原則が2つ導けます。
第一に、arch の検査は必須です。システムコール番号は ABI ごとに異なり、同じ番号 0 が x86-64 では read、別の ABI では全く違う呼び出しを指します。arch を確認せずに番号だけで判定すると、攻撃者が別の呼び出し規約(例:32ビット互換 ABI)に切り替えてフィルタをすり抜けられます。詳しくは システムコールABIの内部 も参照してください。
第二に、フィルタが見られるのはレジスタに入った引数の生の値だけで、ポインタの指す先は読めません。open に渡されたパス文字列の中身でフィルタはできないのです。理由は TOCTOU(time-of-check to time-of-use):BPF が値を読んでから実際にカーネルが使うまでの間に、別スレッドがメモリを書き換えれば検査が無意味になります。引数フィルタはスカラ値(フラグやファイルディスクリプタ番号など)に限る、というのが鉄則です。
アクション:拒否・許可・委譲
フィルタの戻り値が、そのシステムコールの運命を決めます。値は「上位16ビットがアクション、下位16ビットがデータ」という形式で、主なアクションは次のとおりです。
| 戻り値 | 挙動 | 観測のされ方 |
|---|---|---|
| SECCOMP_RET_KILL_PROCESS | プロセス全体を即座に殺す | シグナルで死亡、復帰不可 |
| SECCOMP_RET_KILL_THREAD | 呼び出しスレッドだけを殺す | 旧称KILL、復帰不可 |
| SECCOMP_RET_TRAP | SIGSYSを同期送出する | ハンドラで捕捉でき、続行も可能 |
| SECCOMP_RET_ERRNO | 呼ばずに指定errnoを返す | アプリにはシステムコール失敗に見える |
| SECCOMP_RET_USER_NOTIF | 監視プロセスへ判定を委譲する | 別プロセスが許可/拒否を決める |
| SECCOMP_RET_TRACE | ptracerへ通知し委譲する | トレーサが介入・改変できる |
| SECCOMP_RET_LOG | 許可しつつ監査ログに記録 | 通すが記録が残る |
| SECCOMP_RET_ALLOW | そのまま実行を許可 | 通常通り実行 |
複数のフィルタが積まれている場合、各フィルタを順に実行し、最も「強い」アクション(優先順位が最高のもの)が採用されます。優先順位は KILL_PROCESS > KILL_THREAD > TRAP > ERRNO > USER_NOTIF > TRACE > LOG > ALLOW の順で、上の表の並びがそのまま強さの順です。つまりどれか1つでも KILL を返せば殺され、ERRNO と ALLOW なら ERRNO が勝ちます。あるフィルタが一度課した制限を、後から積んだフィルタが覆せない仕組みです。
なお実装上は「戻り値(の下位ビットを除いたアクション部分)が数値的に小さいほど強い」という規則でほぼ説明できますが、唯一 KILL_PROCESS だけは値が 0x80000000 と最大でありながら最優先になるよう特別扱いされています。値の大小ではなく、上記の優先順位表で覚えるのが安全です。
ERRNO は実務で最も多用されます。拒否したいシステムコールを単に EPERM で失敗させれば、アプリは「権限がない」と認識して穏当に縮退でき、プロセスを殺すよりも互換性が高いからです。TRAP は同期 シグナル SIGSYS を送るので、ハンドラ内でログを取ったりエミュレーションしたりできます。
TRAP が送る SIGSYS は、違反したそのスレッドに、その場で届く同期シグナルです。si_syscall(拒否された番号)や si_arch などの情報が siginfo に載るため、ハンドラは「何が拒否されたか」を正確に知れます。非同期に降ってくる普通のシグナルと違い、原因のシステムコールと一対一で対応するのが利点です。
コンテナサンドボックスでの利用
seccomp が最も広く使われているのが コンテナ です。コンテナはホストのカーネルを共有するため、カーネルの脆弱性を突かれると隔離を越えられます。そこで、コンテナ内のプロセスが呼べるシステムコールを絞り、危険な呼び出し(mount、ptrace、kexec_load、各種 BPF/モジュール操作など)を初めから塞いでおきます。
主要なコンテナランタイムはデフォルトの seccomp プロファイルを持ち、典型的には JSON で「基本は拒否、リストにある安全なものだけ許可」というホワイトリスト方式を取ります。許可リストに載らない呼び出しは ERRNO で失敗させるのが既定です。これにより、おおよそ 40〜60 種の危険なシステムコールが標準で封じられます。
「危険なものだけ拒否する」ブラックリスト方式は脆弱です。新しいシステムコールがカーネルに追加されるたびにリストが穴だらけになり、攻撃者は最新の未掲載呼び出しを狙えます。安全なのは逆——既定で全拒否し、必要なものだけ明示的に許可するホワイトリスト方式です。未知のシステムコールは自動的に拒否側へ倒れます。
USER_NOTIF:判定を別プロセスへ委譲する
SECCOMP_RET_USER_NOTIF は新しく強力なアクションです。フィルタ自身は最終判断をせず、判定を信頼できる監視プロセス(スーパーバイザ)へ委譲します。流れはこうです。
- 対象プロセスが対象のシステムコールを呼ぶと、カーネルはそれをブロックしたまま通知を生成する。
- 監視プロセスはフィルタ設定時に得た通知用ファイルディスクリプタを
ioctl(SECCOMP_IOCTL_NOTIF_RECV)で読み、誰がどの引数で何を呼んだかを受け取る。 - 監視プロセスはポリシーに従い、
SECCOMP_IOCTL_NOTIF_SENDで「この errno で失敗させる」「この戻り値で成功させる」などの応答を返す。 - カーネルは応答に従って対象プロセスの呼び出しを完了させる。
これがなぜ画期的かというと、フィルタ単体では不可能だった引数のポインタ先を見た判定が、監視プロセス経由なら(後述の注意つきで)可能になるからです。たとえば「/safe/ 配下のパスへの open だけ許す」といった、文字列に依存するポリシーを外部で実装できます。
さらに SECCOMP_IOCTL_NOTIF_ADDFD を使えば、監視プロセスが自分で開いたファイルディスクリプタを対象プロセスのテーブルへ注入できます。対象が呼んだ open を監視側が肩代わりして実際に開き、その fd を返す——つまりシステムコールのエミュレーションまで委譲できるわけです。サンドボックス内のプロセスに直接権限を与えず、信頼できる仲介者が代行する構図です。
監視プロセスが引数ポインタの先を読むときは、対象プロセスのメモリを /proc/[pid]/mem 等で読みます。しかし読んだ後、応答を返すまでの間に対象(や共謀スレッド)がそのメモリを書き換えれば、検査は再びすり抜けられます。カーネルは応答時に対象がまだ生きて待っているかを NOTIF_ID_VALID で確かめる仕組みを提供しますが、ポインタ先の内容を安全に検査する責任は監視プロセス側にあります。USER_NOTIF は強力ですが、TOCTOU を魔法のように消すものではありません。
頻出は「seccomp-bpf は何を入力に判定するか」。答えは seccomp_data——番号・arch・IP・引数6個で、ポインタ先は読めない(TOCTOU のため)。アクションの優先順位は「KILL_PROCESS > KILL_THREAD > TRAP > ERRNO > USER_NOTIF > TRACE > LOG > ALLOW」の順で最も強いものが勝つ(KILL_PROCESS だけは値が最大でも最優先の特例)、fork/exec で継承され解除不可。コンテナはホワイトリスト方式が原則。USER_NOTIF は判定を外部委譲し、ADDFD で fd 注入やエミュレーションまで可能、という3点を押さえれば十分です。
まとめ
seccomp はプロセスが呼べる システムコール をカーネル側で機械的に絞り、攻撃面を削る仕組みです。filter モード(seccomp-bpf)は古典 BPF プログラムに seccomp_data(番号・arch・IP・引数)を渡して判定させ、戻り値で KILL / TRAP / ERRNO / USER_NOTIF / TRACE / LOG / ALLOW を選びます。arch 検査は必須、ポインタ先は TOCTOU ゆえ読めない、フィルタは積み重なって緩まず解除不可——これらが原則です。コンテナ ではホワイトリスト方式のプロファイルで危険な呼び出しを既定で封じ、USER_NOTIF を使えば判定や fd 提供そのものを信頼できる監視プロセスへ委譲できます。カーネルとユーザーの境界 に、プロセスごとの可変な門番を置くのが seccomp の本質です。
OS Article
seccompによるシステムコールフィルタリングを実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
seccomp
比較で見る軸
難易度: advanced / カテゴリ: OS / タグ数: 6
導入後に効く点
アクションは KILL(プロセス殺害)・ERRNO(指定エラーを返す)・TRAP(SIGSYS送出)・TRACE(ptracerへ委譲)・LOG・ALLOW があり、優先順位の高いものが勝つ。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- OS
- タグ数
- 6
判断チェックリスト
- 自社の用途が「seccomp / BPF」に近いか確認する。
- 強みである「seccomp-bpf はプロセスに BPF プログラムを取り付け、システムコール番号・引数・アーキテクチャを見て許可/拒否などを判定する。一度有効化すると解除できず、子プロセスにも継承される。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。