systemdサービスをProtectSystemでサンドボックス化
ProtectSystem、PrivateTmp、DynamicUser、NoNewPrivilegesの違いを解説します。各設定が防ぐ操作や壊れる機能、起動時の障害をデバッグする方法を確認できます。
systemd サンドボックス化の役割
systemd サンドボックス化は、unit file を作成する作業の後半です。Type= はサービスの起動方法を決め、ProtectSystem= や PrivateTmp= などのディレクティブは、起動後にサービスがアクセスできる対象を決めます。これらはカーネルの機能であり、mount namespace や seccomp filter を systemd がプロセスに制御を渡す前に適用します。アプリケーションからはこれらが見えず、コードの変更も必要ありません。
デフォルトでは、サンドボックス化は一切行われません。サンドボックス化されていない unit は root として実行され、どこにでも書き込み、システム上のすべてのファイルを読み取り、kernel module をロードできます。その unit がインターネットからアクセス可能な Web アプリケーションであれば、ファイルアップロードの脆弱性1つでサーバー全体が侵害されます。以下の unit では、同じアプリケーションに読み取り専用のファイルシステム、空の /home、他のプロセスから見えない /tmp が与えられます。また、setuid binary を見つけた場合でも root へ昇格する経路はありません。
これらは一度に1つの unit に対して適用されます。あるサービスを強化しても、隣接するサービスには影響しません。そのため、まず公開ポートで待ち受けているサービスから着手します。
unit ファイル全体
notes は小規模な Web サービスです。localhost で待ち受け、/var/lib/notes の下に SQLite データベースを保持し、nginx の背後で動作します。バイナリがフォアグラウンドで動作し続けるため、Type=exec が適しています。Type=simple、exec、forking、notify の違いによって、systemd が起動を追跡する方法が決まります。以下の各ディレクティブについては、後半で、それによって防げる問題と、よく発生する障害を含めて説明します。
[Unit]
Description=Notes web service
After=network-online.target
Wants=network-online.target
[Service]
Type=exec
ExecStart=/opt/notes/bin/notes --listen 127.0.0.1:8080
DynamicUser=yes
StateDirectory=notes
Environment=NOTES_DB=/var/lib/notes/notes.db
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectProc=invisible
NoNewPrivileges=yes
CapabilityBoundingSet=
AmbientCapabilities=
RestrictSUIDSGID=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
RestrictNamespaces=yes
LockPersonality=yes
[Install]
WantedBy=multi-user.target/etc/systemd/system/notes.service に書き込み、sudo systemctl daemon-reload を実行してから、sudo systemctl restart notes.service を実行します。パッケージに含まれる unit の場合は、vendor ファイルを編集しないでください。sudo systemctl edit notes.service を実行すると、/etc/systemd/system/notes.service.d/override.conf に drop-in が開かれます。ここには [Service] の追加設定だけを記述し、パッケージのアップグレード後も維持できます。systemctl cat notes.service は、vendor ファイルを先頭に配置したマージ済みの結果を表示します。
サービスを実行するユーザー
User= がない場合、サービスは root として実行されます。この場合、ここにある他のすべてのディレクティブは被害を抑えるためのものです。この状態を避ける方法は2つあります。
静的な system user。 sudo useradd --system --no-create-home --shell /usr/sbin/nologin notes で作成し、unit に User=notes を設定します。UID は再起動やリブート後も変わりません。サービスが自身の状態ディレクトリ以外のファイルを所有する場合や、バックアップジョブがそのファイルを読み取る必要がある場合に重要です。各サービスに専用の権限のないアカウントを割り当てる理由が、そのまま当てはまります。
DynamicUser=yes。 systemd はサービスの起動時に予約済み範囲から UID を割り当て、サービスの停止時に解放します。ディスク上にアカウントは存在しないため、サービスを削除しても不要なアカウントが残りません。サービスの実行中は、getent passwd notes が systemd の NSS(name service switch)モジュールを通じて名前を解決します。サービスが停止すると、その名前はなくなります。
DynamicUser=yes を設定すると、さらに RemoveIPC=yes、PrivateTmp=yes、ProtectSystem=strict、ProtectHome=read-only の4つも有効になります。1行でサンドボックスの大部分を実現できるため、サンプルの unit は短く記述できます。
破壊されるもの。 動的 UID はサービス間で再利用されるため、任意の場所にあるファイルを所有できません。永続データは StateDirectory=、CacheDirectory=、LogsDirectory= を通す必要があります。systemd は起動するたびにこれらを作成し、現在の UID に chown します。DynamicUser=yes を使用すると、実体のディレクトリは /var/lib/private/notes になり、/var/lib/notes はそこを指すシンボリックリンクになります。/var/lib/private のモードは 0700 で、所有者は root です。そのため、通常ユーザーとして実行するバックアップからは、root には完全に読み取り可能に見えるパスでも Permission denied になります。固定された所有者、SSH key、NFS export、または別のサービスが読み取るファイルが必要な場合は、静的な system user を使用します。
ファイルシステムの見え方: ProtectSystem、ProtectHome、PrivateTmp
ProtectSystem= には 3 つの値があります。yes は /usr とブートディレクトリを読み取り専用でマウントします。full は /etc を追加します。strict は、/dev、/proc、/sys というカーネル API ディレクトリを除き、階層全体を読み取り専用でマウントします。これらのディレクトリは別のディレクティブで扱います。strict から始めて、必要な箇所だけ書き込み可能にしてください。最初に緩く設定して、後から厳しくする運用は定着しません。
書き込み可能にする箇所は ReadWritePaths=/srv/notes/uploads です。必要な数は想定より少なくなります。StateDirectory=、LogsDirectory=、CacheDirectory=、RuntimeDirectory= は ProtectSystem=strict の下で自動的に書き込み可能なままになるため、例の unit には ReadWritePaths= の行がありません。strict では /tmp も読み取り専用になります。ただし、PrivateTmp=yes により unit 専用の書き込み可能なものが渡される場合を除きます。
壊れるもの。 これらのパス以外への書き込みは Read-only file system で失敗します。/etc に自身の設定を保存し直すアプリケーション、/run に直接 PID ファイルを作成するアプリケーション、/opt にプラグインを展開するアプリケーションは、いずれも影響を受けます。エラーからパスを確認し、親ディレクトリではなく、そのパスだけを追加してください。ReadWritePaths= に記載したパスが存在しない場合、警告ではなく起動失敗になります。そのため、任意のパスにはハイフンを付けて ReadWritePaths=-/srv/notes/uploads のようにします。
読み取り専用であることと、見えないことは同じではありません。ProtectSystem=strict の下では、サービスは引き続き /etc/passwd を読み取れます。また、別のアプリケーションに属する、全ユーザーから読み取り可能な Secret も読み取れます。InaccessiblePaths=/etc/ssh /srv/otherapp はサブツリーを unit のビューから完全に取り除きます。サービス自身の Secret については、LoadCredential=dbpass:/etc/notes/dbpass がファイルを unit 専用のディレクトリにコピーします。このディレクトリを読み取れるのは、そのサービスだけです。アプリケーションは $CREDENTIALS_DIRECTORY からファイルを読み取ります。
ProtectHome=yes は /home、/root、/run/user を空に見せます。Web サービスが home ディレクトリを使う必要はありません。これにより、パストラバーサルの脆弱性から /root/.ssh に到達されることも防げます。read-only と tmpfs は、より緩やかな値です。データが実際に home ディレクトリにあるアプリケーションは動作しなくなります。/home/app の下に手動でインストールしたアプリケーションの多くが該当します。データを /var/lib に移すか、ProtectHome=read-only を設定して保護効果が小さくなることを受け入れてください。
PrivateTmp=yes はサービス専用の /tmp と /var/tmp を用意します。これらはサービスの起動時に作成され、停止時に削除されます。これにより、サービス間の一時ファイル競合に関する問題をすべて解消できます。また、クラッシュしても、システム上のすべてのユーザーが一覧表示できるディレクトリに Secret が残ることはありません。
壊れるもの。 /tmp を共有ポイントとして扱うものです。MySQL へ /tmp/mysql.sock 経由で接続するよう設定したサービスは、Can't connect to local MySQL server through socket '/tmp/mysql.sock' と報告するようになります。データベースはホストの /tmp にソケットを作成しますが、サービスは自身の /tmp の中を探すためです。127.0.0.1 を指定するか、/run の下にある実際のソケットパスを指定してください。同じ問題はデバッグ時にも発生します。サービスが /tmp に書き込んだファイルは、シェルの /tmp には表示されません。中を確認するには、サービスの mount namespace に入ってください。
pid=$(systemctl show --property=MainPID --value notes.service)
sudo nsenter --target "$pid" --mount ls -l /tmpPrivateDevices=yes は /dev を、/dev/null、/dev/zero、/dev/urandom など少数の疑似デバイスに置き換えます。物理デバイスは存在しない状態になります。ディスクデバイスノード、/dev/kvm、/dev/net/tun、サウンドカード、GPU はすべて見えなくなります。そのため、ハードウェアトランスコードを行うメディアサーバーは /dev/dri/renderD128 を開けず、ソフトウェア処理にフォールバックするか終了します。サービスが実際に 1 つのデバイスノードを必要とする場合は、その unit で PrivateDevices= を無効にし、DeviceAllow=/dev/dri/renderD128 rw でノードを指定してください。これは、システム上のすべてのデバイスを許可するデフォルトよりも、はるかに限定的です。
サービスが取り得る状態: NoNewPrivileges と capabilities
NoNewPrivileges=yes は、kernel が解除しないプロセスフラグを設定します。その時点以降、そのプロセスと、そこから起動されるすべての子プロセスは、setuid binary や file capability を通じて権限を取得できません。unit 内で最も価値の高い単一行です。ローカル権限昇格の多くの経路を最初の段階で止められるためです。
破棄される機能。 サービス内部から sudo を呼び出す処理は、sudo: effective uid is not 0, is /usr/bin/sudo on a file system with the 'nosuid' option set or an NFS file system without root privileges? を出力するようになります。PAM (pluggable authentication modules) のパスワード検証で unix_chkpwd を別プロセスとして実行する場合も、同じ理由で失敗します。newuidmap を必要とする rootless container tools も同様です。サービスがこれらのいずれかに依存している場合は、directive を削除するのではなく、その依存関係をなくしてください。
CapabilityBoundingSet= は、unit 内のプロセスが保持できる capabilities を制限します。空の値を設定すると、すべての capabilities が削除されます。すでに non-root user で実行しているサービスでは、これは最初のロックではなく、2 つ目のロックです。NoNewPrivileges=yes が capability を取得する通常の方法を阻止するためです。両方を設定してください。これらは異なる形で失敗するため、二重に検出できます。
Web サービスが必要とすることの多い capability は、1024 未満のポートを使用するための CAP_NET_BIND_SERVICE です。non-root process には、許可するだけでなく付与する必要があるため、両方の行が必要です。
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
AmbientCapabilities=CAP_NET_BIND_SERVICEsystemd は権限を削除する前に ambient capabilities を適用するため、これは NoNewPrivileges=yes と併用しても機能します。これらの行がない場合、サービスは起動後に終了し、listen tcp :443: bind: permission denied のようにポートを示すエラーを出力します。ほとんどの VPS 環境では、127.0.0.1:8080 に bind し、nginx または Caddy に 443 を処理させる方が適切です。これにより、unit から capability を完全に排除できます。先頭の ~ はリストを反転するため、CapabilityBoundingSet=~CAP_SYS_ADMIN だけをブロックし、それ以外を許可します。許可リスト形式を推奨します。capabilities は追加され続けるため、拒否リストは時間の経過とともに管理しにくくなります。
カーネルが公開する機能
ProtectKernelTunables=yes は、この unit に対して /proc/sys、/sys およびその配下の書き込み可能なファイルを読み取り専用にします。起動時に sysctl を設定するサービスは動作しなくなります。起動スクリプトは sysctl: setting key "vm.max_map_count": Read-only file system を出力して終了します。値は /etc/sysctl.d/ に設定してください。そこが適切な場所であり、再起動後も維持されます。そのうえで、このディレクティブは有効なままにします。
ProtectKernelModules=yes はモジュールの読み込みを禁止します。modprobe を実行する unit は modprobe: ERROR: could not insert 'nf_conntrack': Operation not permitted になります。サービスからではなく、/etc/modules-load.d/ を使用してブート時にモジュールを読み込んでください。
ProtectKernelLogs=yes は dmesg を取り上げます。ProtectControlGroups=yes は /sys/fs/cgroup を読み取り専用にするため、container runtime や cgroup を自身で管理するソフトウェアは直ちに影響を受けます。ProtectProc=invisible は /proc にある他のユーザーのプロセスを隠します。これにより、侵害されたサービスから別の daemon のコマンドラインや、そこで渡されたパスワードを読み取れなくなります。/proc を走査する monitoring agent では、この設定を無効にする必要があります。
RestrictNamespaces=yes は、サービスによる新しい namespace の作成を停止します。これは container runtime に必要な操作であり、攻撃者が脱出経路を構築する際にも使用します。LockPersonality=yes は kernel の実行ドメインの変更を禁止し、RestrictSUIDSGID=yes はサービスによる setuid ファイルの作成を停止します。いずれも適用コストが低く、通常のアプリケーションで問題になることはほとんどありません。
サービスが通信できる対象
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 はローカルソケット、IPv4、IPv6 を許可し、それ以外のすべてについて socket() を EAFNOSUPPORT で失敗させます。これは seccomp フィルターであるため、x86-64 と arm64 で動作し、現在の VPS を広くカバーします。
壊れるもの。 AF_NETLINK は、予想以上に頻繁に壊れます。glibc の getifaddrs() は netlink ソケットを使用します。Go、Java、.NET のランタイムでインターフェースを列挙する処理も同様です。そのため、自身の IP アドレスを知りたいだけのサービスでも、OSError: [Errno 97] Address family not supported by protocol または各言語に相当するエラーで停止します。その場合、一覧は AF_UNIX AF_INET AF_INET6 AF_NETLINK になります。これはデフォルトよりも大幅に限定されています。AF_PACKET は raw packet capture に必要な権限であり、それ以外で必要になることはほとんどありません。
IPAddressDeny=any と IPAddressAllow=localhost は別の仕組みです。unit の cgroup に BPF フィルターを取り付けます。unit ごとに適用され、nft list ruleset からは見えません。そのため、同じホスト上のデータベースにだけ接続するサービスには適しています。一方、後からそのホストを調査する担当者には分かりにくい設定になります。cgroup BPF に対応していないカーネルでは、systemd は、unit が IP firewall を設定しているものの、ローカルシステムが BPF/cgroup firewalling に対応していないとログに記録します。この場合、ルールは何もせずに機能しないため、想定で判断せず journal を確認してください。
強化した unit が起動しなくなるのはなぜか
上記の各ディレクティブによって、動作していたサービスが起動に失敗することがあります。しかも、その失敗は原因となったディレクティブとは無関係に見えることが少なくありません。手順は常に同じです。journal を読み、1 つのディレクティブだけを緩和して、再テストします。
sudo systemctl restart notes.service
systemctl status notes.service
sudo journalctl -u notes.service -n 50 --no-pagersandbox を定義する行は次のようになります。
notes.service: Failed to set up mount namespacing: No such file or directory
notes.service: Failed at step NAMESPACE spawning /opt/notes/bin/notes: No such file or directory
notes.service: Main process exited, code=exited, status=226/NAMESPACE226/NAMESPACE は、systemd がファイルシステムのビューを構築できなかったことを示します。そのため、バイナリはまったく実行されていません。通常の原因は、ReadWritePaths=、BindPaths=、または InaccessiblePaths= に指定したパスが存在しないことです。228/SECCOMP は、SystemCallFilter= または SystemCallArchitectures= の適用に失敗したことを示します。code=killed, status=31/SYS は別のケースです。プロセスは起動しましたが、フィルターが拒否したシステムコールを実行し、kernel に終了させられています。この切り分けでは、起動しない unit の systemd 終了コードリファレンスを開いておいてください。番号を見ると、namespace の問題かアプリケーションの問題かをすぐに判断できます。
drop-in で、1 回に 1 つのディレクティブだけを緩和してテストします。sudo systemctl edit notes.service を実行し、そこに次の 1 行だけを追加します。
[Service]
ProtectSystem=full再起動します。サービスが起動した場合は、削除ではなく、どのディレクティブを絞り込むべきかが分かります。元に戻して strict にし、アプリケーションが実際に必要とする 1 つのパスについて ReadWritePaths= の行を追加し、再度起動します。サービスが起動しないという理由でブロック全体を削除すると、unit の保護がなくなり、誰も書いた覚えのないコメントだけが残ることになります。
サービスを使わずに sandbox をテストするには、その中で shell を実行します。
sudo systemd-run --pty -p ProtectSystem=strict -p ProtectHome=yes -p PrivateTmp=yes /bin/bashその shell 内では、touch /etc/test は Read-only file system を返し、ls /home には何も表示されません。アプリケーションから見える環境を確認する最も早い方法です。コマンドを手動で実行し、どのコマンドが失敗するかを確認できます。
エラーがまったく表示されない失敗もあります。スペルを誤ったディレクティブは警告になるだけで、サービスはそのディレクティブなしで起動します。
/etc/systemd/system/notes.service:14: Unknown key name 'ProtectSytem' in section 'Service', ignoring.unit は実行されますが、sandbox は適用されません。その後もエラーは発生しません。これを検出するコマンドは 2 つあります。sudo systemd-analyze verify /etc/systemd/system/notes.service は同じ警告を明示的に表示し、systemctl show は実行中のサービスが実際に受け取った内容を表示します。
systemctl show notes.service -p User -p ProtectSystem -p PrivateTmp -p NoNewPrivilegesstrict と記述したのに ProtectSystem=no が返る場合、その unit は想定した内容を読み込んでいません。
systemd-analyze security をチェックリストとして使う
sudo systemd-analyze security notes.service は、unit で使用できるすべてのサンドボックス設定、現在の unit が各設定をどのように扱っているか、各行の判定を出力します。引数なしで実行すると、そのサーバーで読み込まれているすべてのサービスを一覧表示します。インストール前の unit ファイルを確認する場合は、パスを指定して --offline=true を追加します。
出力は、作業項目の一覧として読みます。警告が付いた行を順に確認し、それぞれについて「このサービスにそのアクセスは必要か」と判断します。多くの場合、答えは「不要」です。その場合は該当する行を追加します。必要な場合もあります。メディアサーバーにはデバイスノードが必要です。バックアップエージェントには /home の読み取りが必要です。こうした行には常に警告が残ります。これは失敗ではなく、正しい結果です。
最後に表示される概要の数値は、その上にある行の集計です。このツールは、サービスの動作、保持しているデータ、アプリケーション自体にバグがあるかどうかを把握していません。ツールが確認するすべての行を満たしていても、その unit がサーバー上で最も弱い部分になることがあります。測定しているのはソフトウェアの露出ではなく、unit ファイルの露出だからです。自己ホスト型のパスワードマネージャーを例にすると、この違いが明確になります。Vaultwarden の unit がすべての行を満たしていても、管理者トークンとバックアップファイル が、vault の安全性を左右します。数値を追いかけると、内容を理解していないディレクティブを貼り付けることになります。そのような設定は、パッケージのアップグレード後に壊れやすく、なぜその行が必要だったのか説明できる人もいなくなります。
サンドボックスの範囲
これらのディレクティブは、サービスがアクセスできる対象を制御します。消費できるリソース量は制限しないため、完全にサンドボックス化した unit でも、ホストのすべての CPU コアとメモリを使い切る可能性があります。これは別の設定群であり、systemd サービスの CPU とメモリの制限に関するガイドで説明しています。
また、強制アクセス制御の代わりにはなりません。namespace は unit ごとに設定され、unit ファイルを記述する担当者が定義します。一方、SELinux はシステム全体に 1 つのポリシーを適用します。両者は組み合わせて使用します。どちらか一方で、もう一方が不要になるわけではありません。
最後に、対象となるのは systemd がこの unit 内で起動するプロセスだけです。サービスが socket 経由で helper daemon に処理を渡す場合、その helper はサンドボックス化されていません。同じブロックをその unit にも記述し、ファイルをそのまま信頼せず systemctl show で結果を確認してください。
FAQ
ProtectSystem=strict では、実際に何が読み取り専用になりますか?
/dev、/proc、/sysを除くファイルシステム階層全体です。これらは代わりに PrivateDevices=、ProtectKernelTunables=、ProtectControlGroups= の対象になります。対象には /etc、/var、/srv、/opt、/tmp も含まれます。systemd が追加で書き込み可能にする例外は、systemd が管理するディレクトリです。StateDirectory=、CacheDirectory=、LogsDirectory=、RuntimeDirectory= が該当します。サービスがそれ以外の場所に書き込む必要がある場合は、ReadWritePaths= エントリを明示的に追加する必要があります。読み取り専用でも読み取り自体は可能です。そのため、ホスト上の別の場所にある Secret ファイルは、InaccessiblePaths= に記載しない限りサービスから読み取れます。
サービスが status=226/NAMESPACE で失敗するのはなぜですか?
systemd が mount namespace を作成できなかったため、実行ファイルが起動していません。その直前の journal には、通常 Failed to set up mount namespacing: No such file or directory と記録されます。ほとんどの場合、ReadWritePaths=、BindPaths=、InaccessiblePaths= のいずれかに指定したパスがディスク上に存在しません。ディレクトリを作成するか、エントリの先頭にハイフン(ReadWritePaths=-/srv/notes/uploads)を付けて、パスがない場合に systemd が無視するようにします。パスが存在する場合は、unit のスペルミスを確認し、systemctl cat notes.service で実際の設定を確認してください。確認していない行が drop-in に追加されている可能性があります。
PrivateTmp を使用するサービスは、/tmp 経由でファイルを共有できますか?
いいえ。それが PrivateTmp の目的です。サービスには、実行中だけ存在する新しい /tmp と /var/tmp が割り当てられます。そのため、別のプロセスがホストの /tmp に作成した socket やファイルは、サービスから見えません。/tmp/mysql.sock にあるデータベース socket がよく影響を受けます。対処するには 127.0.0.1 経由で接続するか、クライアントを /run にある実際の socket に向けます。サービス独自の一時ファイルを確認するには、systemctl show --property=MainPID --value からメイン PID を取得し、sudo nsenter --target <pid> --mount でその mount namespace に入ります。
sandbox 化したサービスは、root なしでどのように port 443 を待ち受けますか?
root アカウント全体ではなく、1 つの capability だけを付与します。AmbientCapabilities=CAP_NET_BIND_SERVICE と CapabilityBoundingSet=CAP_NET_BIND_SERVICE を設定し、User= または DynamicUser=yes を維持すると、プロセスは低い port に bind できますが、それ以外の権限は持ちません。bounding set だけを設定するのがよくある誤りです。この場合、capability は許可されていますが付与されていないため、サービスは port を示す permission error で終了します。すでに reverse proxy が動作している VPS では、サービスを 127.0.0.1:8080 に bind し、nginx に 443 を保持させる方法がより適切です。そうすれば unit に capability は必要ありません。
system user を作成する代わりに DynamicUser を使用すべきですか?
サービスがすべてのデータを StateDirectory=、CacheDirectory=、LogsDirectory= の内部に保存する場合に使用します。これは小規模な self-hosted Web アプリケーションの多くに当てはまります。サービスの実行中だけ存在する UID に加えて、PrivateTmp=、ProtectSystem=strict、ProtectHome=read-only、RemoveIPC= も自動的に有効になります。UID を固定する必要がある場合は、static system user を使用します。たとえば、これらのディレクトリの外にあるファイルの所有者にする場合、SSH key を使用する場合、NFS mount を使用する場合、または同じデータを読み取る別のプロセスがある場合です。DynamicUser=yes を使用すると、データは実際には /var/lib/private/notes に保存されることに注意してください。このディレクトリは mode 0700 で root が所有するため、root 以外の backup job は失敗します。