Linux Seccomp実践ガイド:プロセス分離と堅牢化のためのシステムコールフィルタリング

Linux tutorial - IT technology blog
Linux tutorial - IT technology blog

背景と導入の理由

最新のLinux 6.xカーネルは450以上のシステムコールを公開しています。しかし、JSONをHTTP経由で配信する一般的なGo言語のマイクロサービスが必要とするのは、そのうち40程度に過ぎません。ptrace、reboot、mountを呼び出したり、init_module経由でカーネルモジュールをロードしたりする必要は一切ありません。それにもかかわらず、標準的な非特権プロセスであっても、デフォルトではほぼすべてのシステムコールを発行できてしまいます。

カーネルの脆弱性は、依然としてアプリケーションのエクスプロイトからroot権限を奪取するための最も確実な侵入経路の1つです。攻撃者はWebワーカーでリモートコード実行に成功すると、即座にカーネルインターフェースを調査して権限昇格を試みます。もし侵害されたプロセスが生ソケットの作成、サブシェルの起動、あるいは他のプロセスの追跡を行えないよう制限されていれば、攻撃者の試みはそこで行き詰まります。

Secure Computing Mode(Seccomp)は、カーネルの境界でこの問題を解決します。Linux 2.6.12で初めてマージされ、バージョン3.5でBPFフィルタリングによって拡張されたSeccompは、システムコール番号と引数を実行前に検査します。スレッドが禁止されたシステムコールを呼び出した場合、カーネルは即座に介入し、プロセスの強制終了、EPERMエラーコードの返却、またはsyslogへの監査ログの記録を行います。

Seccompは、SELinuxやAppArmorのような強制アクセス制御(MAC)システムとは根本的に異なります。これらのツールがパス名、ネットワークエンドポイント、セキュリティラベルを評価するのに対し、Seccompはさらに1層深いレイヤーで動作します。バイナリがカーネルと対話するために使用する「語彙」そのものを削ぎ落とす仕組みです。

インストール

最近のエンタープライズ向けディストリビューションでは、カーネルレベルでSeccompがデフォルトで有効化されています。サードパーティ製モジュールやカスタムドライバを導入する必要はありません。ただし、プロファイルの構築やテストを行うには、ユーザースペースの開発用ヘッダー、ランタイムライブラリ、トレーシングツールが必要です。

Debian 12およびUbuntu 22.04 / 24.04の場合:

sudo apt-get update
sudo apt-get install -y libseccomp-dev seccomp strace auditd

RHEL 9、Rocky Linux、またはFedoraの場合:

sudo dnf install -y libseccomp-devel strace audit

稼働中のカーネルでSeccompサポートが組み込まれているか確認します:

grep -i seccomp /boot/config-$(uname -r)

出力にCONFIG_SECCOMP=yとCONFIG_SECCOMP_FILTER=yの両方が表示されていることを確認してください。AWS、GCP、Azureの標準クラウドイメージでは、どちらも最初から有効になっています。

設定

Seccompは主に3つのレイヤーでデプロイできます。CやGoのバイナリへの直接コンパイル、systemdユニットファイルによる適用、またはDockerやcontainerdのコンテナプロファイルとしての適用です。運用モデルに最適なレイヤーを選択してください。

1. libseccompによるネイティブアプリケーションのフィルタリング

CやC++のサービスにフィルタを直接組み込むことで、ポータブルなセキュリティを実現できます。コンテナランタイムやsystemdラッパーが存在しない環境であっても、制限はバイナリに追従します。以下のプログラムは、起動直後に厳格な許可リストを適用し、標準I/Oとプロセスの終了のみを許可します:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <seccomp.h>
#include <sys/prctl.h>

int main(void) {
    /* リストにないシステムコールが呼ばれた場合、デフォルトでプロセス全体を終了 */
    scmp_filter_ctx ctx = seccomp_init(SCMP_ACT_KILL_PROCESS);
    if (!ctx) {
        perror("seccomp_init");
        exit(EXIT_FAILURE);
    }

    /* 必要なシステムコールを明示的に許可 */
    seccomp_rule_add(ctx, SCMP_ACT_PERMIT, SCMP_SYS(read), 0);
    seccomp_rule_add(ctx, SCMP_ACT_PERMIT, SCMP_SYS(write), 0);
    seccomp_rule_add(ctx, SCMP_ACT_PERMIT, SCMP_SYS(exit), 0);
    seccomp_rule_add(ctx, SCMP_ACT_PERMIT, SCMP_SYS(exit_group), 0);
    seccomp_rule_add(ctx, SCMP_ACT_PERMIT, SCMP_SYS(fstat), 0);

    /* コンパイルされたBPFプログラムをカーネルにロード */
    if (seccomp_load(ctx) < 0) {
        perror("seccomp_load");
        seccomp_release(ctx);
        exit(EXIT_FAILURE);
    }
    seccomp_release(ctx);

    printf("フィルタがロードされました。標準の書き込み操作は機能します。\n");

    /* ブロック対象のシステムコールを実行してみる: getpid() */
    printf("getpid() を呼び出します...\n");
    pid_t pid = getpid();
    printf("PID: %d (到達不能)\n", pid);

    return 0;
}

テストバイナリをコンパイルして実行します:

gcc -O2 -o seccomp_demo seccomp_demo.c -lseccomp
./seccomp_demo

プログラムは最初のステータス行を標準出力に書き込みます。しかし、getpid()が呼び出された瞬間、カーネルはBad system call (core dumped)を出力してスレッドを即座に終了させます。プロセスが最後のprint文に到達することはありません。

2. systemdによるデーモンの堅牢化

レガシーデーモンやプロプライエタリなツールでは、内部のフックポイントが公開されていることは稀です。このようなワークロードに対して、systemdはSystemCallFilterディレクティブを介して宣言的なフィルタリングを提供します。性能オーバーヘッドはほぼゼロ(大半のベンチマークで1%未満)です。

以下のユニットを/etc/systemd/system/api-gateway.serviceとして保存します:

[Unit]
Description=内部APIゲートウェイ
After=network.target

[Service]
Type=simple
User=www-data
Group=www-data
ExecStart=/usr/local/bin/api-gateway --port 8080
Restart=always

# ネイティブアーキテクチャの強制とシステムコールの許可リスト
SystemCallArchitectures=native
SystemCallFilter=@system-service @network-io
SystemCallFilter=~@privileged @resources @obsolete @mount @reboot
SystemCallErrorNumber=EPERM

[Install]
WantedBy=multi-user.target

systemdは、一般的なシステムコールを@system-serviceや@network-ioといった定義済みセットにバンドルしています。チルダ接頭辞(~)を付けることで、そのベースラインから危険なグループを除外できます。アプリケーションがブロックされた操作を試みた場合、systemdはワーカープロセスを突然killするのではなく、EPERMを返します。

ユニットの変更を反映します:

sudo systemctl daemon-reload
sudo systemctl restart api-gateway.service

3. Dockerプロファイルによるコンテナ化サービスの制限

Dockerはデフォルトでreboot、clock_settime、kexec_loadなど約44のシステムコールをブロックします。このデフォルト設定は、広範な互換性と基本的なセキュリティのバランスを取っています。重要なマイクロサービスでは、この大まかな網を専用の許可リストに置き換えることが推奨されます。

以下のポリシーをstrict-profile.jsonとして保存します:

{
  "defaultAction": "SCMP_ACT_ERRNO",
  "architectures": [
    "SCMP_ARCH_X86_64"
  ],
  "syscalls": [
    {
      "names": [
        "read",
        "write",
        "open",
        "openat",
        "close",
        "fstat",
        "futex",
        "epoll_wait",
        "epoll_ctl",
        "exit_group"
      ],
      "action": "SCMP_ACT_ALLOW"
    }
  ]
}

カスタムフィルタを適用してコンテナを起動します:

docker run --rm -it \
  --security-opt seccomp=strict-profile.json \
  alpine:latest sh

リストにないシステムコールに依存するコマンドを実行すると、コンテナのシェル内で直接権限エラー(Permission denied)が発生して失敗します。

検証とモニタリング

厳格な許可リストをいきなり本番環境へ適用してはなりません。必要なシステムコールの予測を誤ると、めったに発生しないエッジケースに遭遇した瞬間にサービスがクラッシュしてしまいます。障害を防ぐために、以下のプロファイリングと監視ワークフローを活用してください。

必要なシステムコールのトレース

制限をかける前に、バイナリの実際のランタイム動作をマッピングします。負荷テストの実行中に、サービスをstrace配下で起動します:

strace -c -f -S name /usr/local/bin/api-gateway --port 8080

プロセスに対して少なくとも5,000〜10,000件の実際のリクエストを送信します。データベース接続、ヘルスチェックエンドポイント、メトリクス収集、エラーハンドリングルーチンを網羅的に実行してください。Ctrl+Cでstraceを終了すると、システムコールごとの総呼び出し回数、エラー数、経過時間を示す集計テーブルが出力されます。このレポートを基に許可リストを構築します。

プロセスのSeccomp状態の確認

procファイルシステムを通じて、フィルタが対象PIDを保護しているか確認します:

grep -i seccomp /proc/$(pgrep -f api-gateway)/status

カーネルはSeccomp:に対して以下の3つの整数値のいずれかを返します:

  • 0(無効): このプロセスではシステムコールフィルタリングが有効になっていません。
  • 1(Strictモード): prctl(PR_SET_SECCOMP, 1)で有効化されたレガシーモードです。プロセスはread、write、_exit、sigreturnのみを使用できます。
  • 2(Filterモード): seccomp(SECCOMP_SET_MODE_FILTER, ...)によってアクティブなBPFフィルタがシステムコールを評価しています。

監査ログによるフィルタ違反の監視

テストフェーズ中は、ルールのアクションをSCMP_ACT_LOGに設定します。カーネルはシステムコールの実行を許可しつつ違反をすべて記録するため、稼働率を損なうことなく正確なテレメトリを取得できます。

直近の監査レコードを検索します:

sudo ausearch -m SECCOMP -ts recent

トリガーされたフィルタは/var/log/audit/audit.logにログエントリを生成します:

type=SECCOMP msg=audit(1708701234.567:890): auid=1000 uid=33 gid=33 ses=1 \
subj=unconfined pid=1423 comm="api-gateway" exe="/usr/local/bin/api-gateway" \
syscall=101 compat=0 ip=0x7f9a1234 arch=c000003e code=0x0

ausyscallを使用して10進数のシステムコール番号を変換します:

ausyscall x86_64 101

x86_64においてシステムコール101はptraceに変換されます。外部ライブラリが正当にこの操作を必要としている場合は、プロファイルに追加してください。もし検証されていない依存関係がプロセスインジェクションを試みていたのであれば、Seccompフィルタが実行時コストゼロでエクスプロイトの阻止に成功したことを意味します。

Share: