サーバー上の管理パネルは、ほぼ必ず同じ壁に突き当たります。ウェブのプロセスが、権限を持たない処理をしなければならないのです。アドレスを遮断する、ポートを開ける、ジャーナルを読む。まず思い浮かぶ答えは、sudoers に狭い一行だけを与え、それ以外は与えないことです。
www-data ALL=(root) NOPASSWD: /usr/bin/fail2ban-client set * banip *
この行は「遮断だけ」と読めます。実際の意味は「root として何でも」です。以下では、なぜそうなるのか、自分のサーバーで五分で確かめる方法、そしてこうしたルールを何に置き換えるのかを扱います。
* が実際にしていること
決定的な点は、sudo が引数を一つずつ比べるのではなく、コマンド行全体を比べることです。そしてパターン中の * は空白を——つまり引数の境界を——平然と越えていきます。したがってルールの中ほどに置かれたリテラル banip は何も制限しません。banip という語がコマンドのどこかに現れればそれで足り、その前後には何でも渡せるからです。
次に探すのは、実行すべきコマンドを渡せるプログラムです。私たちの場合、それはルールの中に書かれていました。fail2ban では遮断アクションがテキストとして定義され、そのテキストは稼働中に再定義できます。
fail2ban-client get portscan actions
sudo fail2ban-client set portscan action nftables-multiport actionban "<コマンド>" banip 192.0.2.10
sudo fail2ban-client set portscan banip 192.0.2.11
一つ目の呼び出しが実在するアクションのテキストを差し替え、二つ目が jail を発動させます。すると root で動いている fail2ban が、差し込まれた内容を実行します。私たちはこれを 7 月 4 日に自社のサーバーで試しました。/root にファイルが root 権限で作られた——つまりウェブのプロセスは、狭く見えるルールからマシン上の全権限を得たわけです。
その途中で、他者の解説には通常見られない三つの細部が分かりました。
setの前に置く-cは sudo が切り落とします。パターンではsetがバイナリ名に直接くっついていて、その前には何も挿し込めません。banipの語を含まないaddactionとactionも通りません。しかし必要なものはすべてset … banip …という一つのコマンドに収まり、それでパターンは満たされます。- アクション名は実在するものでなければなりません。さもなければ差し替える対象がありません。名前は
fail2ban-client get <jail> actionsが示します。
同じ一覧に並ぶ他のもの
fail2ban はここで悪者ではなく、特別な例でもありません。危ないのは NOPASSWD とアスタリスクの組み合わせそのものです。自社のサーバーでその隣に見つけたものを挙げます。
journalctl *— ジャーナルはページャー経由で開き、ページャーからはシェルが起動できます。見た目はログ閲覧のルールですが、実際には root シェルです。処方はパターンを狭めることではなく、systemd-journalグループです。そうすればjournalctlは sudo なしで動きます。grep * /var/log/fail2ban.log—grepの第一引数はパターンですが、アスタリスクは二つ目のパスも渡せるようにしてしまい、grep は root で走ります。このルールで/etc/shadowを読むのはコマンド一つです。置き換えも同じで、ログ閲覧にはadmグループを使います。ufw --force *と、裸のufw allow/deny/delete *— ウェブのプロセスがファイアウォールを丸ごと止められます。とくに具合が悪いのは、どちらの場合もパネルがこれらのルールをまったく使っていなかったことです。インストーラーの初期版が残した死んだ許可でした。lynis-scan.sh *—"$@"を root プロセスへ渡すラッパーは、制限のないルールと等価です。
自分のところに何があるかを見る方法
見るべきはファイルではなく、ある特定のユーザーの実効権限です。PHP-FPM のプールが実際に動いているユーザーのことで、これは必ずしも www-data ではありません(当社の一台では、パネルは admin で動いていました)。
ps -o user= -C php-fpm | sort -u
sudo -l -U www-data
sudo -l -U admin
次にファイルの一覧そのもの。ここには、私たちが時間を取られた落とし穴が二つあります。
sudo grep -rn 'NOPASSWD' /etc/sudoers /etc/sudoers.d/
ls -la /etc/sudoers.d/
一つ目。ルールは二か所に同時に存在します。当社では危険な行が /etc/sudoers.d/monitor にも、本体の /etc/sudoers にもありました。本体のほうには、インストーラーの異なるバージョンが積み上げた八つほどの複製が入っていました。片方だけ掃除して安心するのは、穴を開けたままにする定番の方法です。
二つ目。sudo は名前にドットを含むファイルを無視します。/etc/sudoers.d/ にある www-data.bak3 は有効なルールのように見え、有効なルールのように読めますが、効力はありません。これは両方向に働きます。「ルールはあるのに権限がない」と、設定の複製が「すぐ隣に置いてある」という偽の安心です。
何に置き換えるか
パターンを狭めても無駄です。行のどこにアスタリスクがあっても、課題は振り出しに戻ります。成り立つ方法は一つだけ。位置の固定された引数を受け取り、自分で検証し、何も付け足す余地なくプログラムを呼ぶ root ラッパーです。
#!/bin/sh
# /usr/local/sbin/arciveo-f2b — ban|unban <jail> <ip>、それ以外は不可
set -eu
action="$1"; jail="$2"; ip="$3"
case "$action" in ban|unban) ;; *) echo "invalid action"; exit 1 ;; esac
case "$jail" in *[!a-zA-Z0-9_-]*|'') echo "invalid jail"; exit 1 ;; esac
printf '%s' "$ip" | grep -Eq '^[0-9a-fA-F:.]+$' || { echo "invalid ip"; exit 1; }
case "$action" in
ban) exec /usr/bin/fail2ban-client set "$jail" banip "$ip" ;;
unban) exec /usr/bin/fail2ban-client set "$jail" unbanip "$ip" ;;
esac
ラッパーは root の所有、権限は 755。そして——これは任意ではありません——ウェブから書き込み可能であってはなりません。さもなければこの仕組み全体が無意味になります。sudoers に残るのはこれだけです。
www-data ALL=(root) NOPASSWD: /usr/local/sbin/arciveo-f2b
引数はルールに列挙しません。検証はスクリプト自身が行い、その中の位置は固定されているので、アクションのテキストを滑り込ませる場所がありません。読むだけのルール——各種ステータス、ss、ipset list——は別の行に分け、可能な場所ではやはりアスタリスクを使いません。
sudo をグループで置き換える話は別に触れておきます。方向は正しく、ジャーナルには systemd-journal、ログには adm を使うほうが、どんな sudo ルールより安く安全です。ただしウェブのユーザーをグループから当てずっぽうに外すことはできません。パネルの載ったホストで www-data を一つのグループから外したところ、全サイトが一斉に 403 になりました。そこでは Apache が www-data で動き、サイトのファイルは別のユーザーの所有で、読み取り権限はまさにそのグループ所属から来ていたのです。復旧には Apache と PHP-FPM の完全な再起動——リロードではなく——が必要でした。古いワーカーは以前のグループ構成を保持し続け、「動く、そして 403」という状態を生むからです。
変更後の確認
sudoers のファイルを差し替える前に、構文を確認しておく必要があります。さもないと sudo が一切使えない状態になりえます。
sudo visudo -cf /etc/sudoers.d/monitor
その後、古い経路が死んでいることを、まさにそのユーザーの名前で確認します。
sudo -k
sudo -u www-data sudo -n fail2ban-client set portscan banip 192.0.2.10
答えは a password is required であるべきで、アドレスの代わりにごみを渡してラッパーを呼んだ場合は invalid ip が返るべきです。ここには固有の落とし穴があります。sudo -v は確認を十五分キャッシュし、その後は sudo -n による検査がどれも「成功」で通ってしまいます。まさにそれで私たちは一度、サーバーに存在しないルールを確認したことにしてしまいました。ですから検査の前の sudo -k は必須です。
観測する意味
sudo のルールが変わることはまれですが、変わるときは音を立てません。インストーラーが書き足し、ホスティングのパネルが自分の分を加え、削除されたパッケージが自分の行を残していきます。一度きりの棚卸しは今日あるものを閉じるだけで、次の更新が何を持ち込むかについては何も語りません。実務的な結論は単純です。誰が root になれるかの一覧は、サーバーの他の状態と並べて目に見える場所に置くべきで、事故のあとの記憶に置くべきではありません。まとまった形でどう見えるかは、下のデモページで確認できます。