LinuxとDockerコンテナのSeccompプロファイル設定:システムコールフィルタリングによるアタックサーフェス削減

Security tutorial - IT technology blog
Security tutorial - IT technology blog

以前、自分のサーバーがSSHブルートフォース攻撃を受けて、真夜中に緊急対応する羽目になった。それ以来、ファイアウォールやfail2banだけでなく、カーネルレベルのより深い部分まで含め、セキュリティ層全体を見直すようになった。その過程で見つけてすぐに採用したものの一つがSeccomp(Secure Computing Mode)だ。

Dockerコンテナをデフォルト設定のまま運用しているエンジニアは多いが、そのコンテナが何百ものシステムコールをホストカーネルに向けて発行できることを知らない場合も多い。ptracemountのような危険なsyscallが悪用されると、攻撃者はコンテナの内側からホストへの権限昇格が可能になる。Seccompプロファイルは、そういったsyscallが悪用される前に入口を塞ぐ仕組みだ。

Seccompとは何か、なぜ重要なのか

シンプルに言うと、Seccompはカーネルレベルのフィルターで、あるプロセスがどのsyscallを呼び出せるかを制御し、それ以外をすべてブロックする。Linux 5.xには335以上のsyscallが存在するが、通常のWebアプリが実際に必要とするのは50〜70個程度だ。残りのkexec_loadcreate_modulemountなどは一般的なアプリケーションには不要だが、攻撃者にとっては非常に魅力的なターゲットになる。

DockerにはデフォルトのSeccompプロファイルがあり、最も危険な約44個のsyscallを事前にブロックしている。聞こえはいいが、44/335ではまだ290以上が解放されたままだ。機密データを扱うサービスやインターネットに直接公開されるサービスには、デフォルトに頼らず独自のカスタムプロファイルを作成することをお勧めする。

Seccompの2つのモード

  • SECCOMP_MODE_STRICTreadwriteexitsigreturnのみ許可する非常に制限的なモード。実際にはほとんど使われない
  • SECCOMP_MODE_FILTER:BPF(Berkeley Packet Filter)を使って柔軟なルールを定義するモード。Dockerとsystemdはこちらのモードを採用している

DockerコンテナへのSeccompプロファイル設定

カスタムSeccompプロファイルの作成

プロファイルはJSON形式で記述する。defaultActionフィールドは、リストにないsyscallに対する挙動を決定する。一般的にはSCMP_ACT_ERRNO(EPERMエラーを返す)かSCMP_ACT_KILL(プロセスを即座に終了)を使用する。custom-seccomp.jsonファイルを作成しよう:

{
  "defaultAction": "SCMP_ACT_ERRNO",
  "architectures": ["SCMP_ARCH_X86_64", "SCMP_ARCH_X86", "SCMP_ARCH_X32"],
  "syscalls": [
    {
      "names": [
        "accept", "accept4", "access", "arch_prctl",
        "bind", "brk", "capget", "capset",
        "chdir", "clock_gettime", "clone", "close",
        "connect", "dup", "dup2", "dup3",
        "epoll_create1", "epoll_ctl", "epoll_pwait", "epoll_wait",
        "execve", "exit", "exit_group",
        "fchmod", "fchown", "fcntl", "fstat", "fsync",
        "futex", "getcwd", "getdents64", "getegid",
        "geteuid", "getgid", "getpeername", "getpid",
        "getppid", "getrandom", "getrlimit", "getsockname",
        "getsockopt", "gettid", "gettimeofday", "getuid",
        "ioctl", "kill", "listen", "lseek", "lstat",
        "madvise", "mmap", "mprotect", "munmap",
        "nanosleep", "open", "openat", "pipe", "pipe2",
        "poll", "ppoll", "prctl", "pread64", "prlimit64",
        "pwrite64", "read", "readlink", "readv",
        "recvfrom", "recvmsg", "rename", "renameat2", "rmdir",
        "rt_sigaction", "rt_sigprocmask", "rt_sigreturn",
        "sched_yield", "select", "sendfile", "sendmsg", "sendto",
        "set_robust_list", "set_tid_address",
        "setgid", "setuid", "setsockopt",
        "sigaltstack", "socket", "stat", "statfs",
        "symlink", "tgkill", "unlink", "unlinkat",
        "uname", "wait4", "write", "writev"
      ],
      "action": "SCMP_ACT_ALLOW"
    }
  ]
}

カスタムプロファイルでコンテナを起動する

# カスタムSeccompプロファイルで実行する
docker run --security-opt seccomp=./custom-seccomp.json \
  -p 3000:3000 \
  my-nodejs-app

# unconfined実行 — デバッグ時のみ使用可、本番環境では絶対に使わないこと
docker run --security-opt seccomp=unconfined my-nodejs-app

docker-compose.ymlでの設定

version: '3.8'
services:
  webapp:
    image: my-nodejs-app
    security_opt:
      - seccomp:./custom-seccomp.json
    ports:
      - "3000:3000"

デバッグ:アプリが実際に必要とするsyscallの特定

実際の疑問はこうだ:ホワイトリストに漏れが生じないよう、アプリが必要とするsyscallをどうやって把握すればいいのか?通常実行中にstraceでアプリをトレースするのが答えだ:

# アプリ起動時のユニークなsyscall一覧をトレース・抽出する
strace -f -o /tmp/syscalls.log node server.js
grep -oP '^[a-z_]+' /tmp/syscalls.log | sort -u

# 実行中のプロセスをトレースする(特定のPIDを指定)
strace -f -e trace=all -p 1234 2>&1 | awk -F'(' '{print $1}' | sort -u

プロファイルを適用したのにアプリがエラーを出して原因がわからない?テスト用にdefaultActionSCMP_ACT_LOGに変更してみよう。プロセスをkillする代わりにブロックされたsyscallをログに記録するので、デバッグが大幅に楽になる:

dmesg | grep -i seccomp
# または
journalctl -k | grep seccomp

systemd経由でLinuxサービスにSeccompを適用する

Dockerを使わずホストLinux上で直接アプリを動かしている場合、systemdならさらに便利なSeccomp適用方法がある。機能グループ別に分類されたsyscallグループが用意されており、syscallを一つひとつ列挙する必要がない:

# /etc/systemd/system/myapp.service
[Unit]
Description=My Web Application
After=network.target

[Service]
User=appuser
ExecStart=/usr/local/bin/myapp
Restart=on-failure

# Seccomp:Webサービスに適したsyscallのみ許可する
SystemCallFilter=@system-service
SystemCallErrorNumber=EPERM

# その他のハードニング設定も組み合わせる
NoNewPrivileges=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectSystem=strict
ProtectHome=true

[Install]
WantedBy=multi-user.target

各グループのsyscall一覧を確認し、サービスをリロードする:

# @system-serviceグループに属するsyscallを確認する
systemd-analyze syscall-filter @system-service

# 便利なグループ例:@network-io, @file-system, @process, @io-event
systemd-analyze syscall-filter @network-io

# リロードして違反がないか確認する
systemctl daemon-reload
systemctl restart myapp
journalctl -u myapp | grep -i seccomp

PythonコードからSeccompを直接適用する

DockerやsystemdなしでSeccompをPythonコードに直接組み込みたい場合は、python-libseccompライブラリが活用できる。インストールは簡単だ:

pip install seccomp
import seccomp

# デフォルトアクション:許可されていないsyscallを呼び出した場合はKILL
filt = seccomp.SyscallFilter(defaction=seccomp.KILL)

allowed = [
    "read", "write", "open", "openat", "close",
    "stat", "fstat", "mmap", "mprotect", "munmap",
    "brk", "rt_sigaction", "rt_sigreturn",
    "ioctl", "pread64", "pwrite64",
    "socket", "connect", "accept", "bind", "listen",
    "send", "recv", "sendto", "recvfrom",
    "exit", "exit_group", "futex", "gettimeofday",
]

for syscall in allowed:
    try:
        filt.add_rule(seccomp.ALLOW, syscall)
    except Exception:
        pass

# 即座に適用する — 以降のコードはsyscallが制限される
filt.load()
print("Seccompフィルターが適用されました")

まとめ

SeccompはファイアウォールやAppArmorの代替にはならない。各セキュリティ層にはそれぞれの役割がある。Seccompの強みは最も低いレイヤーでブロックできる点だ:syscallがカーネルに届く前に遮断する。--cap-drop ALL--read-only--security-opt no-new-privilegesと組み合わせれば、コンテナは互いに独立した三つの層でロックされる。

デプロイ時のクイックチェックリスト:

  • seccomp=unconfinedを本番環境で使うのは厳禁 — 一時的なデバッグであっても例外はない
  • ホワイトリストを書く前にstraceまたはSCMP_ACT_LOGを使って実際のsyscallをプロファイリングすること — 推測に頼ってはいけない
  • systemdの@system-serviceは安定した出発点 — 各サービスのニーズに応じてカスタマイズする
  • プロファイル適用後は特にエッジケースを丁寧にテストすること — 大きなファイルの処理やネットワーク切断時に、想定外のsyscallを呼び出すアプリも存在する

あのブルートフォース攻撃のインシデントを処理した夜以来、Seccompプロファイルは新しいサービスを本番環境にデプロイする際に最初に設定するものの一つになった — 特にインターネットに直接公開されるコンテナには欠かせない。

Share: