sudo と SSH 鍵でアクセスを固める
構文検査に通る「正しい」設定で、自分の sudo を失います。SSH では入れるのに、そのファイルを消せない ── 内側の手段が尽きた状態から、ハイパーバイザに降りて復旧します。クラウドでは、この「外側」に手が届きません。
| 所要時間 | 約 70 分(概念 20 分+演習 50 分) |
|---|---|
| 章タイプ | standard |
| 前提知識 | Part 1 Chapter 1(SSH の基礎)/ Part 3 Chapter 3(ユーザ・グループ・鍵認証の手順) |
| 使用機材 | 踏み台 workstation01 + 演習用サーバ learner01(Proxmox 電源・スナップショット操作あり) |
| 関連章 | 前: Chapter 3「ユーザ・グループ・権限を設計する」/ 後: Chapter 5「ディスクを拡張する」 |
正しい順番でやらないと、直せる人が誰もいなくなる
Chapter 3 ではアクセスを設計しました。この章で扱うのは、それを安全に固める作業です。設計と運用は別物で、固める作業には固有の事故があります。
この章で実機でしか学べないのは ハイパーバイザから、内側では直せなくなったサーバに外側から手を入れることです。SSH が通らなくなる、あるいは sudo が完全に死ぬ ── どちらも本番で日常的に起きます。そのとき残る道は、仮想化基盤に降りることだけです。クラウドのマネージドサービスでは、この「外側」に手が届きません。
この章の安全設計(先に読んでください)
「締め出しを体験する」章ですが、あなた自身(管理者 ops)が SSH から締め出される演習ではありません。
フェーズ A ① 実効設定を実測して記録し、ops の鍵で入れることを先に証明する
↓ (=回復経路の確保)
フェーズ B ② snapshot を取る(★まだ何も変更していない時点)
↓
フェーズ C ③④ 演習ユーザと鍵を用意し、sudo を最小権限で設計する
↓
フェーズ D ⑤ 壊す ── D-1: 鍵を持たない labuser02 が締め出される
↓ D-2: 検査を通した sudoers 配置で ops 自身の sudo が死ぬ
フェーズ E ⑥ 戻す ── Proxmox snapshot rollback(★内側に手段が無い=唯一の出口)
↓
フェーズ F ⑦ 正しい順番でやり直し、実効値で検証し、片付ける
ops はフェーズ A で鍵を登録し、鍵で入れることを確認してから先へ進みます。したがって ops の SSH は最後まで生きています。しかしフェーズ D-2 では、構文検査を通るルールが ops 自身の sudo を打ち消します。「入れるのに、直せない」という状態です。
D-1 と D-2 は壊す対象が違います。 D-1 で SSH から締め出されるのは使い捨てユーザ
labuser02、D-2 で sudo を失うのは管理者opsです。opsの SSH は残すので、「入れない」ではなく「入れるのに直せない」を安全に体験できます。
この章を終えるとできること
アクセスを固める 4 動詞を手に覚えます。
では足りない
効くことは別
安全を保証しない
戻せない状態を戻す
3 つのしくみを解きほぐす
1. sudoers.d ── 「コマンド名だけ」の許可は最小権限ではない
Chapter 3 で「ops は sudo を持つが labuser01 は持たない」という非対称を見ました。この非対称を意図して作るのが /etc/sudoers.d/ です。
最初に押さえるべき落とし穴があります。sudoers にコマンドのパスだけを書くと、そのコマンドは任意の引数で実行できます。
# ❌ 最小権限ではない
labuser01 ALL=(ALL) NOPASSWD: /usr/bin/apt
apt は -o で任意の設定を上書きでき、APT の hook(DPkg::Pre-Invoke など)はシェルで実行されます。つまりこのルールを与えられた人は sudo apt -o DPkg::Pre-Invoke::="任意のコマンド" … の形で root として何でも実行できます。「apt だけ」ではなく「root そのもの」を渡したのと変わりません。
同じ落とし穴は systemctl・find(-exec)・tar(--checkpoint-action)・vi(:!)など、外部コマンドを呼べる/引数で挙動が変わるコマンド全般にあります。
# ⭕ 方法1: 引数まで固定する(この組み合わせしか通らない) labuser01 ALL=(root) NOPASSWD: /usr/bin/systemctl status ssh # ⭕ 方法2(本章で採用): 引数を受け取らないラッパーだけを許可する labuser01 ALL=(root) NOPASSWD: /usr/local/sbin/ch4-ssh-log ""
#!/bin/sh
set -eu
exec /usr/bin/journalctl -u ssh -n 20 --no-pager # "$@" を渡さない = 引数を通さない
末尾の "" は sudoers の記法で「引数を一切受け付けない」という意味です。ラッパー側の防御("$@" を渡さない)と合わせた二重防御になっています。
ラッパー自体の所有者とパーミッションが本体です。 ラッパーを一般ユーザが書き換えられるなら、それを許可することは root を渡すことと同じです。
root:root・0755が必須条件です。
配置は 3 段です。構文が正しいことと、権限設計が安全であることは別問題です。visudo が見るのは文法だけで、「このルールが自分の権限を奪うか」までは判断しません。
TMP="$(mktemp)" # 固定の /tmp パスは symlink 攻撃の的になる printf '%s\n' 'labuser01 ALL=(root) NOPASSWD: /usr/local/sbin/ch4-ssh-log ""' > "$TMP" sudo visudo -cf "$TMP" # ① 単独ファイルの構文検査 sudo install -o root -g root -m 0440 "$TMP" /etc/sudoers.d/10-ch4-labuser01 # ② モードごと配置 rm -f "$TMP" sudo visudo -c # ③ ★ sudoers 全体の整合を検査
visudo -cf <ファイル> はそのファイル単体しか見ません。配置後は sudo visudo -c で全体を検査します。mv してから chmod する手順だと、その一瞬だけ不適切なモードで sudoers.d にファイルが存在するため、install でモードごと置きます。
2. sshd_config.d と「鍵限定」── ファイル名では効くことを保証できない
ここには落とし穴が 3 つあります。
落とし穴1: PasswordAuthentication no だけでは鍵限定になりません。
OpenSSH のパスワード入力経路は 1 本ではありません。PasswordAuthentication が塞ぐのは password 方式だけで、PAM 経由の keyboard-interactive は別のキーワードです。
PubkeyAuthentication yes ← 公開鍵を有効に保つ(消すと誰も入れない) PasswordAuthentication no ← password 方式を無効化 KbdInteractiveAuthentication no ← keyboard-interactive(PAM)を無効化 ★別設定
落とし穴2: 99- という名前を付けても「後から上書き」はされません。
sshd_config は先頭付近で Include /etc/ssh/sshd_config.d/*.conf を読み、glob は辞書順に展開されます。そして OpenSSH は各キーワードについて 最初に得た値を採用します。sudoers の「後にマッチしたルールが勝つ」とは逆です。
辞書順に読む: 00-hardening.conf PasswordAuthentication no ← 最初に読まれた値が採用 ⭕ 50-cloud-init.conf PasswordAuthentication yes ← 無視される ── もしファイル名が 99- だったら ── 50-cloud-init.conf PasswordAuthentication yes ← こちらが採用される 99-hardening.conf PasswordAuthentication no ← 無視される ❌「置いたのに効かない」
落とし穴3: ただし Match は文書化された例外です。
Match ブロックの中は、条件に合致した接続に対して先行する値を上書きします。「最初の値が勝つ」の唯一の例外です。だからこそ「どの接続として評価するか」を指定しないと、実際の値は分かりません。それが sshd -T の -C です。
sudo sshd -T -C user=ops,host=workstation01,addr=<workstation01のIP> \ | grep -iE '^(passwordauthentication|kbdinteractiveauthentication|pubkeyauthentication)'
-C の user は接続してくるユーザ名、host・addr は接続元です(接続先ではありません)。-C を付けないと Match が評価されず、実際の接続時と違う値が出ます。
適用は 1 コマンドに閉じます:
sudo sshd -t && sudo systemctl reload ssh。&&でつなぐのが要点で、別々の行に書くと検査が失敗しても次の行が走って壊れた設定が適用されます。
反映確認の「正」は
sshd -T -Cの実効値です。systemctl is-active sshは構成によって答えが変わります。SSH が socket activation(ssh.socket)で動く環境ではssh.serviceが常駐しないためis-active sshはinactiveを返し、reloadも意味を持ちません(新規接続ごとに新しい設定で sshd が起動するため)。STEP ① でどちらの構成かを実測します。
3. スナップショットによる復旧 ── 内側から直せない VM に外から手を入れる
SNAP="ch4-$(date +%Y%m%d-%H%M%S)" # 一意名にする(固定名は再実行時に重複エラー) qm snapshot <VMID> "$SNAP" --description "Ch4 演習前の状態" qm listsnapshot <VMID>
このスナップショットに RAM は含まれません。
--vmstate 1を付けていないため、保存されるのは VM 構成とディスクだけです。rollback 後の VM は停止状態になりqm startが必要です。また、稼働中に取るディスク snapshot は「その瞬間に電源を抜いた状態」に相当します(crash-consistent)。
qm shutdown <VMID> # まずは正常停止(ゲストへ ACPI 停止要求) qm status <VMID> # stopped を確認 qm rollback <VMID> "$SNAP" qm start <VMID>
qm rollback は内部で停止処理も行うため事前停止は必須ではありませんが、状態を明示するために先に停止します。使うのは qm stop(強制電源断相当)ではなく qm shutdown(正常停止)で、ゲストが応答しないときだけ qm stop に切り替えます。
実機価値: クラウドにもスナップショットはありますが、ハイパーバイザに直接入り、内側から手を出せなくなったゲストを外側から復旧する経験は、抽象化の向こう側では得にくいものです。基盤を握っているからこそできる操作を、この章で体験します。
試してみよう:絞り込み、締め出し、外側から戻す
| 項目 | 値 |
|---|---|
| 出発地点 | 踏み台 workstation01 |
| 演習対象 | learner01(設定変更・スナップショット操作) |
| 管理者ユーザー | ops(NOPASSWD sudo・本章で鍵を登録し回復経路にする) |
| 演習用ユーザー(鍵あり) | labuser01(限定 sudo の対象) |
| 使い捨てユーザー(鍵なし) | labuser02(★締め出される側) |
| sudoers 設定 | /etc/sudoers.d/10-ch4-labuser01(限定 sudo)+ /etc/sudoers.d/99-ch4-lab(D-2 の自己剥奪) |
| sudo ラッパー | /usr/local/sbin/ch4-ssh-log(root:root 0755) |
| SSH 設定 | /etc/ssh/sshd_config.d/50-ch4-lab.conf(Match User labuser02 ブロックのみ) |
| 所要時間 | 約 50 分(7 ステップ) |
Match User labuser02 ブロックの中身だけです。同じサーバでは他の受講者がパスワードでログインしており、グローバルな PasswordAuthentication を切るとその人たちを巻き込んで壊します。「自分の演習範囲に閉じる」こと自体が、本番でアクセス制御を触るときの第一の作法です。ターミナルの使い方
3 つ使います。T3 は STEP ②・⑥・⑦の最後でしか使いません。
| 何 | 用途 | |
|---|---|---|
| T1 | learner01 の ops セッション | サーバ側の設定変更。章の間ずっと閉じない |
| T2 | workstation01 のローカルシェル | 鍵生成・接続試験 |
| T3 | Proxmox ホストの ops セッション | qm 操作(②・⑥・⑦末) |
STEP 01 · 実測し、回復経路を確保する(フェーズ A)
【T1: learner01 の ops セッション】 まず前回の残骸が無いことを確認します。残骸があればこの章を始めず、先に片付けてください(既存のものを自動で消さない=他章の成果物を壊さないため)。
ssh ops@learner01 for p in /etc/sudoers.d/10-ch4-labuser01 /etc/ssh/sshd_config.d/50-ch4-lab.conf \ /usr/local/sbin/ch4-ssh-log; do [ -e "$p" ] && echo "NG: $p が既に存在します(この章を開始しないでください)" done for u in labuser01 labuser02; do id "$u" >/dev/null 2>&1 && echo "NG: ユーザ $u が既に存在します" done echo "残骸チェック完了"
残骸チェック完了
実効設定を記録します。章末はこの記録値へ戻すため、必ず控えてください。
sudo sshd -T -C user=ops,host=workstation01,addr=<workstation01のIP> \ | grep -iE '^(passwordauthentication|kbdinteractiveauthentication|pubkeyauthentication|allowusers|allowgroups|denyusers|denygroups|usepam)' \ | tee ~/ch4-baseline-sshd.txt
usepam yes pubkeyauthentication yes passwordauthentication yes kbdinteractiveauthentication no
allowusers/allowgroups/denyusers/denygroupsに値が出たら、この章はここで中止してください。 そのサーバには既存のユーザ許可リストという別のアクセス制御が入っています。labuser02を通すにはそのリストを変更することになり、「自分の演習用ファイルしか触らない」という本章の境界を越えます。値を記録して指導者の判断を仰いでください。
SSH の起動方式も実測します(reload の意味が変わるため)。
systemctl is-active ssh; systemctl is-enabled ssh 2>/dev/null systemctl is-active ssh.socket 2>/dev/null; systemctl is-enabled ssh.socket 2>/dev/null
| 実測結果 | 以降の扱い |
|---|---|
ssh.service が active | sudo sshd -t && sudo systemctl reload ssh をそのまま使う |
ssh.socket が enabled で ssh.service が inactive | reload は不要。sudo sshd -t だけ実行し、確認は sshd -T -C で行う |
socket activation 構成では
reloadが「失敗」しますが、それは手順の破綻ではありません。Unit ssh.service is not active等が出ても、sshd -tが通っていれば設定ファイルは正しく、次の接続から反映されています。この章での「反映できたか」の判定は、常にsshd -T -Cの実効値です。
回復経路を用意します。受講環境の鍵ですでに ops@learner01 へ入れますが、それでもこの章専用の鍵をもう 1 本作ります。これから sshd を触る以上、「与えられた経路が生きているはず」と「自分で確かめた別経路がある」は別物だからです。この規律自体が本章の主題の一部です。
# ops が sudo 以外に root を取れないことを確認(D-2 の前提) sudo passwd -S ops; sudo passwd -S root
ops L 2026-05-09 0 99999 7 -1 root L 2025-08-05 0 99999 7 -1
2 列目の L は Locked(パスワード施錠)です。ops も root もパスワードでは入れません。これが STEP ⑤ で効いてきます。
【T2: workstation01】 この章専用の鍵を作り、既存の経路を使って登録します。
ssh-keygen -t ed25519 -N '' -C 'ch4-ops-recovery' -f ~/.ssh/ch4_ops_ed25519 ssh-copy-id -i ~/.ssh/ch4_ops_ed25519.pub ops@learner01
ssh-copy-id は既存の鍵で認証して新しい公開鍵を追加します。これが通ること自体が「既存の経路が生きている」証明です。失敗したら回復経路が無い状態なので、ここで中止し、sshd の設定には進まないでください。
# ★鍵だけで入れることを証明する。通らない限り先へ進まない ssh -o IdentitiesOnly=yes -o PasswordAuthentication=no -o KbdInteractiveAuthentication=no \ -i ~/.ssh/ch4_ops_ed25519 ops@learner01 'id -un; echo OPS_KEY_LOGIN_OK'
ops OPS_KEY_LOGIN_OK
OPS_KEY_LOGIN_OK が出ない限り、STEP ② へ進まないでください。この鍵が、これから起こす事故のあとに戻ってくるための命綱です。最後に「もう 1 つの入口」も確認します。Proxmox の Web UI から該当 VM のコンソールを開いてください。learner01 login: というテキストのログイン画面が出て、ユーザー名の一覧は表示されません(画面に利用者名を並べないため、グラフィカルなログイン画面は停止してあります)。root と ops はパスワード施錠なのでここからは入れません。つまりコンソールも回復手段にはならないことを、目で確認しておきます。
STEP 02 · スナップショットを取る(フェーズ B・★まだ何も変更していない)
【T3: Proxmox ホスト】
ssh ops@<Proxmox管理IP> qm list | grep learner01 qm config <VMID> | grep -E '^(scsi|virtio|sata|ide|efidisk|tpmstate)[0-9]*:' # 全ボリューム pvesm status # 保存先の空き容量
SNAP="ch4-$(date +%Y%m%d-%H%M%S)"; echo "$SNAP" # ★この名前を控える qm snapshot <VMID> "$SNAP" --description "Ch4 演習開始前のクリーン状態" qm listsnapshot <VMID>
ch4-20260728-101147 `-> ch4-20260728-101147 2026-07-28 10:11:47 Ch4 演習開始前のクリーン状態 `-> current You are here!
SNAP を保持したまま STEP ⑥ まで閉じないでください(閉じた場合は qm listsnapshot <VMID> で名前を確認し SNAP=<控えた名前> で再設定します)。STEP 03 · 演習ユーザを作り、labuser02 だけパスワードで入れるようにする(フェーズ C-1)
【T1: learner01 の ops セッション】
sudo useradd -m -s /bin/bash labuser01 sudo useradd -m -s /bin/bash labuser02 echo 'labuser02:Ch4-Throwaway-Pass' | sudo chpasswd id labuser01; id labuser02
この固定パスワードは本章専用の使い捨てです。実運用ではこの書き方をしません(コマンド履歴に平文が残ります)。
labuser02はこのあとの rollback で消えます。
labuser02 だけがパスワードで入れる状態を Match ブロックで作ります。
sudo tee /etc/ssh/sshd_config.d/50-ch4-lab.conf > /dev/null << 'EOF' Match User labuser02 PubkeyAuthentication yes PasswordAuthentication yes KbdInteractiveAuthentication yes EOF sudo chmod 644 /etc/ssh/sshd_config.d/50-ch4-lab.conf sudo sshd -t && sudo systemctl reload ssh
★ここが -C の出番です。同じサーバなのに、接続してくるユーザによって実効値が違うことを確かめます。
for U in ops labuser02; do echo "--- user=$U ---" sudo sshd -T -C user=$U,host=workstation01,addr=<workstation01のIP> \ | grep -iE '^(passwordauthentication|kbdinteractiveauthentication)' done
--- user=ops --- passwordauthentication yes kbdinteractiveauthentication no --- user=labuser02 --- passwordauthentication yes kbdinteractiveauthentication yes
kbdinteractiveauthentication の値が ops は no、labuser02 は yes と分かれました。同じ設定ファイル、同じサーバ、同じ瞬間なのに、誰として接続するかで答えが違います。-C を付けなければこの違いは見えません。
【T2: workstation01】 labuser01 の鍵を作ります。
ssh-keygen -t ed25519 -N '' -C 'ch4-labuser01' -f ~/.ssh/ch4_labuser01_ed25519 cat ~/.ssh/ch4_labuser01_ed25519.pub # この1行をコピーする
【T1: learner01】 公開鍵を配置します。下の <ここに公開鍵を貼り付ける> を、コピーした 1 行に置き換えてから実行してください。
sudo -u labuser01 mkdir -p /home/labuser01/.ssh sudo chmod 700 /home/labuser01/.ssh sudo -u labuser01 tee /home/labuser01/.ssh/authorized_keys << 'EOF' <ここに公開鍵を貼り付ける> EOF sudo chmod 600 /home/labuser01/.ssh/authorized_keys sudo chown -R labuser01:labuser01 /home/labuser01/.ssh # ★プレースホルダのまま貼っていないかを確認する(1 が出れば正しい) sudo grep -c '^ssh-ed25519 ' /home/labuser01/.ssh/authorized_keys
1 が出ることを必ず確認してください。0 なら、プレースホルダの行をそのまま貼り付けています。
【T2: workstation01】 両方のユーザが、意図した方式で入れることを確認します。
# labuser01 は鍵で入れる ssh -o IdentitiesOnly=yes -o PasswordAuthentication=no -o KbdInteractiveAuthentication=no \ -i ~/.ssh/ch4_labuser01_ed25519 labuser01@learner01 'id -un' # labuser02 はパスワードで入れる(この後これを奪う) ssh -o PubkeyAuthentication=no -o IdentitiesOnly=yes -o PreferredAuthentications=password \ -o NumberOfPasswordPrompts=1 labuser02@learner01 'echo LABUSER02_PASSWORD_LOGIN_OK'
ssh host 'id'と 1 行で書く理由: 「ssh hostで入ってからidを打つ」形で手順書に 2 行並べると、コピー&ペーストしたときにidが手元のマシンで実行されてしまい、確認になりません。
STEP 04 · sudoers.d でラッパーだけを許可する(フェーズ C-2)
【T1: learner01 の ops セッション】 まずラッパーを置きます。
TMPW="$(mktemp)" cat > "$TMPW" << 'EOF' #!/bin/sh set -eu exec /usr/bin/journalctl -u ssh -n 20 --no-pager EOF sudo install -o root -g root -m 0755 "$TMPW" /usr/local/sbin/ch4-ssh-log rm -f "$TMPW" ls -l /usr/local/sbin/ch4-ssh-log
-rwxr-xr-x 1 root root 67 7月 28 10:13 /usr/local/sbin/ch4-ssh-log
root root と、他者に書き込みが無いことが要点です。ここが緩ければ、以降の設計はすべて無意味になります。
次に sudoers を 3 段で配置します。
TMP="$(mktemp)" printf '%s\n' 'labuser01 ALL=(root) NOPASSWD: /usr/local/sbin/ch4-ssh-log ""' > "$TMP" sudo visudo -cf "$TMP" # ① 単独検査 sudo install -o root -g root -m 0440 "$TMP" /etc/sudoers.d/10-ch4-labuser01 # ② 配置 rm -f "$TMP" sudo visudo -c # ③ ★全体検査 ls -l /etc/sudoers.d/10-ch4-labuser01
/tmp/tmp.rzysBWype4: 正しく構文解析されました /etc/sudoers: 正しく構文解析されました /etc/sudoers.d/10-ch4-labuser01: 正しく構文解析されました (中略) -r--r----- 1 root root 62 7月 28 10:13 /etc/sudoers.d/10-ch4-labuser01
【T2: workstation01】 最小権限が効いていることを確認します。判定は終了コードで行います(エラー文言は sudo のバージョンで揺れるため)。
K="-o IdentitiesOnly=yes -i $HOME/.ssh/ch4_labuser01_ed25519" ssh $K labuser01@learner01 'sudo -l' # 許可されたラッパーは通る ssh $K labuser01@learner01 'sudo -n /usr/local/sbin/ch4-ssh-log >/dev/null 2>&1; echo "wrapper exit=$?"' # ★ラッパーの中身に直接オプションを渡す経路が無い ssh $K labuser01@learner01 \ 'sudo -n /usr/bin/journalctl -u ssh --output=cat >/dev/null 2>&1; echo "直接実行 exit=$? (0以外なら設計通り)"'
ユーザー labuser01 は learner01 上で コマンドを実行できます
(root) NOPASSWD: /usr/local/sbin/ch4-ssh-log ""
wrapper exit=0
直接実行 exit=1 (0以外なら設計通り)
sudo -l にラッパー 1 件だけが並び、journalctl への直接実行が拒否されれば、最小権限の設計が機能しています。ラッパー経由なら同じログが読めるのに、直接は読めない ── ここが「引数を渡さない」の効果です。
STEP 05 · 壊す(フェーズ D)── 2 段階で「直せない」を作る
D-1: 鍵を持たない labuser02 が締め出される
【T1: learner01 の ops セッション】 Match ブロックの中身を no に書き換えます。グローバル設定には触れません。
sudo tee /etc/ssh/sshd_config.d/50-ch4-lab.conf > /dev/null << 'EOF' Match User labuser02 PubkeyAuthentication yes PasswordAuthentication no KbdInteractiveAuthentication no EOF sudo sshd -t && sudo systemctl reload ssh # labuser02 だけが変わり、ops は不変であることを確認する for U in ops labuser02; do echo "--- user=$U ---" sudo sshd -T -C user=$U,host=workstation01,addr=<workstation01のIP> \ | grep -iE '^(passwordauthentication|kbdinteractiveauthentication)' done
--- user=ops --- passwordauthentication yes kbdinteractiveauthentication no --- user=labuser02 --- passwordauthentication no kbdinteractiveauthentication no
ops の値は STEP ① で記録した baseline と同じままです。これが「演習が他の利用者を巻き込んでいない」ことの証跡になります。
【T2: workstation01】 締め出しを確認します。ポートが開いていることと、認証が通ることは別問題なので分けて見ます。
# ポートは開いているか timeout 30 bash -c 'until (echo > /dev/tcp/learner01/22) 2>/dev/null; do sleep 2; done' \ && echo "port 22 OPEN" # サーバが提示する認証方式を見る ssh -vv -o PubkeyAuthentication=no -o IdentitiesOnly=yes \ -o PreferredAuthentications=password,keyboard-interactive \ -o NumberOfPasswordPrompts=0 -o ConnectTimeout=10 \ labuser02@learner01 'echo SHOULD_NOT_APPEAR' 2>&1 \ | grep -E 'Authentications that can continue|Permission denied'
port 22 OPEN debug1: Authentications that can continue: publickey labuser02@learner01: Permission denied (publickey).
Authentications that can continue: publickey が要点です。成否ではなくサーバが提示した認証方式で、パスワード経路が閉じたことを証明できました。labuser02 は鍵を持たないので、入る手段がありません。
ここまでなら、まだ簡単に直せます。
opsは SSH で入れるので、T1 でsudo rm /etc/ssh/sshd_config.d/50-ch4-lab.confすれば終わりです。本番の事故が深刻になるのは、その「直す手段」自体を失ったときです。次でそれを作ります。
D-2: 検査を通した sudoers 配置で、ops 自身の sudo が死ぬ
ここで再現するのは、検査を飛ばす事故ではありません。visudo -cf が exit=0 を返す、文法的に正しいルールで自分の権限を剥奪する事故です。
sshd_config … 最初に現れた値が勝つ(後から同じ設定を書いても無視される) sudoers … 最後にマッチしたルールが勝つ(後から書いたルールが上書きする)
「設定は先勝ち」「設定は後勝ち」と決めつけるのは、どちらも危険です。設定の種類ごとに規則を確かめ、実効値で検証する必要があります。
/etc/sudoers.d/ はファイル名の辞書順に読まれます。既存の 90-ops より後に読ませるため、ファイル名を 99-ch4-lab にします。数字は飾りではなく読み込み順です。
ops ALL= (ALL) !ALL │ │ │ └─ 実行してよいコマンド =「どれも許可しない」 │ │ └──────────── どのユーザーとして実行してよいか │ └──────────────────────── どのホストで └─────────────────────────────────── 誰が
! は否定です。ALL が「すべてのコマンド」なので、!ALL は「すべてのコマンドを禁止」を意味します。
# 演習用ファイルを作る(構文は完全に正しい) TMPF="$(mktemp)" printf '%s\n' 'ops ALL=(ALL) !ALL' > "$TMPF" # ★ 検査は通る。visudo は「文法」しか見ない sudo visudo -cf "$TMPF"; echo "visudo -cf exit=$?" # 90-ops より後に読ませるため 99- で始まる名前にする sudo install -o root -g root -m 0440 "$TMPF" /etc/sudoers.d/99-ch4-lab rm -f "$TMPF" # 何が起きたか sudo -n id -un 2>&1; echo "exit=$?"
visudo -cf exit=0 sudo: パスワードが必要です exit=1
visudo は exit=0 を返しました。 文法はどこも間違っていません。それでも、最後に読まれた !ALL が既存の NOPASSWD:ALL を打ち消し、ops の sudo は使用不能になりました。構文の正しさは、意味の安全を保証しません。1 つの経路が塞がっただけでは「内側から直せない」とは判断できません。事故対応と同じように、考えられる経路を並べて 1 つずつ潰します。
# 1. sudo で演習ファイルを消せるか sudo -n rm /etc/sudoers.d/99-ch4-lab 2>&1 | tail -2 # 2. 一般ユーザの権限で消せるか rm /etc/sudoers.d/99-ch4-lab 2>&1 # 3. root のパスワード認証へ切り替えられるか echo "" | timeout 5 su - root -c "id" 2>&1 | tail -2 # 4. polkit 経由で昇格できるか timeout 5 pkexec --disable-internal-agent id 2>&1 | tail -2 # 5. SSH アクセス自体は残っているか whoami
| # | 試す経路 | 実測結果 | なぜ塞がっているか |
|---|---|---|---|
| 1 | sudo -n rm … | パスワードが必要です | !ALL で NOPASSWD が消えた。ops のパスワードは施錠されており供給できない |
| 2 | rm … | 許可がありません | ファイルは root 所有・0440。一般ユーザでは消せない |
| 3 | su - root | 認証失敗 | root のパスワード欄は施錠済みでログインできない |
| 4 | pkexec … | No authentication agent found. | 非対話の SSH セッションには認証エージェントがない |
| 5 | SSH で whoami | ops(アクセス成功) | アクセスは残っているが、権限だけを失っている |
5 番が最も重要です。入れないから直せないのではなく、入れるのに直せません。 ops は SSH で入れてファイルも見えますが、root 権限を得られないため消せません。思いつく 5 経路がすべて塞がったので、残るのはこのサーバの外側だけです。
STEP 06 · 戻す(フェーズ E)── ハイパーバイザに降りる
【T3: Proxmox ホスト】
echo "$SNAP" # 巻き戻し先を確認(空なら qm listsnapshot で再設定) qm shutdown <VMID> # 正常停止(ゲストへ ACPI 停止要求) qm status <VMID> # stopped を確認 qm rollback <VMID> "$SNAP" qm start <VMID> qm status <VMID>
status: stopped Logical volume "vm-<VMID>-disk-0" successfully removed. Logical volume "vm-<VMID>-disk-0" created. rollback done in 1s status: running
qm shutdownが返ってこない場合(ゲストが ACPI に応答しない場合)だけqm stop <VMID>を使います。qm stopは電源ケーブルを抜くのと同じ強制停止です。
【T1: learner01 へ再接続】(rollback で元のセッションは切れています)
ssh ops@learner01 # ★まず sudo が生き返っていることを確認する sudo -n true; echo "sudo exit=$?" # ②の時点=何も作っていない状態に戻っている for p in /etc/ssh/sshd_config.d/50-ch4-lab.conf /etc/sudoers.d/10-ch4-labuser01 \ /etc/sudoers.d/99-ch4-lab /usr/local/sbin/ch4-ssh-log; do [ -e "$p" ] && echo "残存: $p" || echo "消えた: $p" done for u in labuser01 labuser02; do id "$u" >/dev/null 2>&1 && echo "残存: $u" || echo "消えた: $u" done
sudo exit=0 消えた: /etc/ssh/sshd_config.d/50-ch4-lab.conf 消えた: /etc/sudoers.d/10-ch4-labuser01 消えた: /etc/sudoers.d/99-ch4-lab 消えた: /usr/local/sbin/ch4-ssh-log 消えた: labuser01 消えた: labuser02
ops の権限を奪う 99-ch4-lab が存在しないため、確実に直ります。もしスナップショットを 99-ch4-lab を配置した後に取っていたら、自己剥奪ルールごと復元されて詰みます。
opsの回復用鍵は残っています。 STEP ① で登録したのは STEP ② の snapshot を取る前なので、巻き戻し先にも含まれているからです。消えたのは STEP ③ 以降に作ったものだけ ── 「いつ snapshot を取ったか」が、何が残り何が消えるかを決めます。
STEP 07 · 正しい順番でやり直し、検証し、片付ける(フェーズ F)
同じ設定を、今度は 鍵を先に用意してから適用します。
【T1: learner01 の ops セッション】 labuser02 を作り直し、パスワードは与えずに Match を先に置きます。
sudo useradd -m -s /bin/bash labuser02 sudo tee /etc/ssh/sshd_config.d/50-ch4-lab.conf > /dev/null << 'EOF' Match User labuser02 PubkeyAuthentication yes PasswordAuthentication no KbdInteractiveAuthentication no EOF sudo chmod 644 /etc/ssh/sshd_config.d/50-ch4-lab.conf
【T2: workstation01】 鍵を作ります。
ssh-keygen -t ed25519 -N '' -C 'ch4-labuser02' -f ~/.ssh/ch4_labuser02_ed25519 cat ~/.ssh/ch4_labuser02_ed25519.pub
【T1: learner01】 鍵を配ってから反映します。
sudo -u labuser02 mkdir -p /home/labuser02/.ssh sudo chmod 700 /home/labuser02/.ssh sudo -u labuser02 tee /home/labuser02/.ssh/authorized_keys << 'EOF' <ここに公開鍵を貼り付ける> EOF sudo chmod 600 /home/labuser02/.ssh/authorized_keys sudo chown -R labuser02:labuser02 /home/labuser02/.ssh sudo grep -c '^ssh-ed25519 ' /home/labuser02/.ssh/authorized_keys # 1 を確認 sudo sshd -t && sudo systemctl reload ssh
pubkeyauthentication yes passwordauthentication no kbdinteractiveauthentication no
pubkeyauthentication yes passwordauthentication yes kbdinteractiveauthentication no
ops は passwordauthentication yes のままです。Match User labuser02 の外側は一切影響を受けていません ── 「自分の演習範囲に閉じる」が実効値で確認できました。
【T2: workstation01】 検証します。鍵は通り、パスワード経路は閉じていることを方式で確認します。
# 1. 鍵は通る(今回は締め出されない) ssh -o IdentitiesOnly=yes -o PasswordAuthentication=no -o KbdInteractiveAuthentication=no \ -i ~/.ssh/ch4_labuser02_ed25519 labuser02@learner01 'id -un; echo KEY_AUTH_OK' # 2. パスワード経路は閉じている ssh -vv -o PubkeyAuthentication=no -o IdentitiesOnly=yes \ -o PreferredAuthentications=password,keyboard-interactive \ -o NumberOfPasswordPrompts=0 -o ConnectTimeout=10 \ labuser02@learner01 'echo SHOULD_NOT_APPEAR' 2>&1 \ | grep -E 'Authentications that can continue|Permission denied'
labuser02 KEY_AUTH_OK debug1: Authentications that can continue: publickey labuser02@learner01: Permission denied (publickey).
SHOULD_NOT_APPEAR は表示されていません。パスワード経路で入れていたら、この文字列が出るように仕込んでありました。「出なかったこと」自体が証拠です。
PasswordAuthentication no でも、鍵を先に用意したかどうかで結果が正反対になりました。 これが本章の要点です。設定の内容は同じ。違うのは順番だけです。
Permission denied (publickey)は「正しく閉じた」印です。 括弧の中はエラー原因ではなく、サーバが受け付ける残りの認証方式を示します。publickeyだけが残っている=パスワード経路が無い、ということです。
章末クリーンアップ
【T1: learner01】 STEP ① で記録した状態へ戻します(決め打ちで yes にしない)。
sudo rm -f /etc/ssh/sshd_config.d/50-ch4-lab.conf sudo userdel -r labuser02 2>/dev/null; sudo rm -f /var/mail/labuser02 sudo sshd -t && sudo systemctl reload ssh # ①の記録と一致するかを差分で確認する sudo sshd -T -C user=ops,host=workstation01,addr=<workstation01のIP> \ | grep -iE '^(passwordauthentication|kbdinteractiveauthentication|pubkeyauthentication|allowusers|allowgroups|denyusers|denygroups|usepam)' \ > /tmp/ch4-after-sshd.txt diff ~/ch4-baseline-sshd.txt /tmp/ch4-after-sshd.txt && echo "baseline へ復帰しました" # この章で足した鍵行だけを消す(他の行は残す) sed -i '/ch4-ops-recovery/d' ~/.ssh/authorized_keys rm -f ~/ch4-baseline-sshd.txt /tmp/ch4-after-sshd.txt
labuser01・2 つの sudoers ファイル・ラッパーの削除は不要です。 STEP ⑥ の rollback で既に消えています。手で消すのは rollback より後に作ったもの(labuser02・50-ch4-lab.conf・opsの鍵行)だけです。
rm -f ~/.ssh/ch4_labuser01_ed25519 ~/.ssh/ch4_labuser01_ed25519.pub \ ~/.ssh/ch4_labuser02_ed25519 ~/.ssh/ch4_labuser02_ed25519.pub \ ~/.ssh/ch4_ops_ed25519 ~/.ssh/ch4_ops_ed25519.pub ls ~/.ssh/ch4_* >/dev/null 2>&1 && echo "NG: 演習鍵が残っています" || echo "演習鍵 削除済み"
echo "$SNAP"; qm delsnapshot <VMID> "$SNAP"; qm listsnapshot <VMID>
| 確認項目 | 期待値 |
|---|---|
sudo sshd -T -C user=ops,… の実効値 | STEP ① の記録と一致(diff なし) |
| グローバル設定 | 演習前後で不変(触っていない) |
ls /etc/ssh/sshd_config.d/50-ch4-lab.conf | No such file or directory |
ls /etc/sudoers.d/99-ch4-lab | No such file or directory |
id labuser01 / id labuser02 | いずれも「そのようなユーザは存在しません」 |
sudo visudo -c | すべて「正しく構文解析されました」 |
ops の authorized_keys | ch4-ops-recovery 行なし・既存行は保持 |
Proxmox の ch4-<日時> スナップショット | 削除済み |
詰まったら
- ハマりポイント: よくある詰まりと対処を以下に列挙しています
- AI に相談: 演習中の疑問を章の内容を踏まえて AI に聞けます
ハマりポイント
STEP ① で OPS_KEY_LOGIN_OK が出ない
先へ進まないでください。この鍵が唯一の命綱です。
ls -la ~/.ssh で authorized_keys が 0600、.ssh が 0700、所有者が ops であることを確認します。sudo journalctl -u ssh -n 50 に Authentication refused: bad ownership or modes が出ていれば、それが原因です。
STEP ① で allowusers などに値が出た
この章を中止してください。既存のユーザ許可リストを変更しないと labuser02 が接続できず、それは本章の境界(演習用ファイルしか触らない)の外側です。
Match 側に許可を足して回避しようとしないでください。グローバルの制御を演習で書き換えるのは、この章が最も戒めていることそのものです。値を記録して判断を仰いでください。
STEP ⑤ D-2 のあと、rollback せずに直したい
内側での削除を試し続けないでください。STEP ⑤ の 5 経路で、内側の手段が尽きていることは確認済みです。
唯一の例外は、STEP ① で Web コンソールから root で入れると分かっている場合です。そのときだけコンソールから rm /etc/sudoers.d/99-ch4-lab で直せます。この演習環境では root がパスワード施錠のため入れないので、rollback が唯一の道です。
STEP ⑥ のあと SSH がつながらない
起動が遅い: crash-consistent なスナップショットからの復帰では、初回起動でファイルシステムの復旧処理が走ることがあります。timeout 120 のポート待ちで吸収してください。
Connection timed out during banner exchange: ポートは開いているが sshd がまだ応答を返し始めていない状態です。1〜2 分待って再試行してください。
REMOTE HOST IDENTIFICATION HAS CHANGED: 同じ VM のスナップショットなのでホスト鍵は同一のはずで、通常は出ません。出た場合は ssh-keygen -R learner01 で古いエントリを消して再接続します。
上記以外の詰まりと対処を一覧にまとめます。
| 起きたこと | 対処 |
|---|---|
③ labuser01 に鍵で入れない | sudo grep -c '^ssh-ed25519 ' /home/labuser01/.ssh/authorized_keys が 0 ならプレースホルダのまま貼っている。0700/0600/所有者も確認 |
③ -C の出力が ops と labuser02 で同じ | Match が効いていない。-C の付け忘れか、50-ch4-lab.conf のインデント(Match 行の次から字下げ)を確認 |
④ visudo -cf がエラー | 一時ファイルの構文誤り。cat "$TMP" で内容を見て書き直す。エラーのまま install しない |
⑤ D-2 のあと sudo がパスワードを求める | 正常な実験結果。 ops のパスワードは施錠されているので入力しても通らない。⑥へ進む |
② qm snapshot がエラー | pvesm status で空き容量、qm config <VMID> で全ディスクの保存先を確認。同名エラーなら SNAP を作り直す |
⑥ qm shutdown が返ってこない | ゲストが ACPI に応答していない。qm stop <VMID>(強制停止)を使う |
T3 を閉じて $SNAP が消えた | qm listsnapshot <VMID> で名前を確認し SNAP=<その名前> で再設定 |
AI に相談
修了確認
この章で体験した 2 つの「逆」を確認します。
問 1 · なぜ visudo が通ったのに sudo が死んだのか
STEP ⑤ D-2 で配置した ops ALL=(ALL) !ALL は、visudo -cf が exit=0 を返しました。それなのに ops の sudo は使えなくなりました。なぜですか? また、ファイル名を 99- にした理由を説明してください。
解答を見る
visudo が検査するのは文法だけだからです。「このルールが運用者自身の権限を奪うか」という意味の妥当性は判断しません。ops ALL=(ALL) !ALL は文法として完全に正しく、意味としては「ops はどのコマンドも実行してはならない」です。
99- にした理由: /etc/sudoers.d/ はファイル名の辞書順に読まれ、sudoers は最後にマッチしたルールが勝ちます。既存の 90-ops より後に読ませないと、後から読まれた NOPASSWD:ALL に上書きされてしまい、この事故は再現しません。10- のような名前では先に読まれて負けます。数字は飾りではなく読み込み順です。
そしてここが重要ですが、sshd_config はこれと逆です。
| 設定 | 勝つのは | 番号を大きくすると |
|---|---|---|
sudoers.d | 最後にマッチしたルール | 強くなる(後勝ち) |
sshd_config.d | 最初に現れた値 | 弱くなる(先勝ち) |
「設定は後勝ち」と覚えてしまうと sshd で失敗し、「先勝ち」と覚えると sudoers で失敗します。種類ごとに規則を確かめ、実効値で検証するしかありません。
問 2 · 同じ設定なのに結果が正反対になった理由
STEP ⑤ D-1 と STEP ⑦ では、まったく同じ PasswordAuthentication no を適用しました。それなのに、片方では締め出され、もう片方では問題なく入れました。何が違ったのですか? また、締め出しの確認に Permission denied ではなく Authentications that can continue を見たのはなぜですか。
解答を見る
違いは順番だけです。
| やった順序 | 結果 | |
|---|---|---|
| D-1(STEP ⑤) | パスワードを閉じた。鍵は配っていない | 入る手段がゼロ = 締め出し |
| フェーズ F(STEP ⑦) | 先に鍵を配ってからパスワードを閉じた | 鍵で入れる = 事故なし |
設定ファイルの中身は 1 文字も違いません。「安全な設定」という概念は存在せず、あるのは「安全な順序」だけです。これは本番でアクセス制御を変更するときの鉄則そのものです。
Authentications that can continue を見た理由: これはサーバが提示した認証方式の一覧で、サーバ側の状態そのものを表します。一方 Permission denied は「入れなかった」という結果でしかなく、鍵が無いのか、パスワードが違うのか、経路が閉じているのかを区別できません。
Authentications that can continue: publickey なら、パスワード経路がサーバ側で閉じていることが確定します。成否ではなく方式で見るのが、この種の検証の作法です。
あわせて、SHOULD_NOT_APPEAR という文字列を仕込んでおき、それが出ないことを証拠にしている点にも注目してください。「エラーが出た」ではなく「通っていたら必ず出るはずのものが出ていない」で判定しています。
段階ヒント(問 1・問 2 共通)
段階ヒント 1 段目:順序と実効値がこの章のすべて
この章で守る規律は 3 つです。①回復経路が通ることを確認してから設定を触る ②何も変更していない時点で snapshot を取る ③反映確認は systemctl の結果ではなく sshd -T -C の実効値で行う。詰まったときは、この 3 つのどれを飛ばしたかを疑ってください。
段階ヒント 2 段目:要点だけを抜き出したコマンド
ssh -o IdentitiesOnly=yes -o PasswordAuthentication=no -o KbdInteractiveAuthentication=no \
-i ~/.ssh/ch4_ops_ed25519 ops@learner01 'echo OPS_KEY_LOGIN_OK'
for U in ops labuser02; do echo "--- user=$U ---" sudo sshd -T -C user=$U,host=workstation01,addr=<workstation01のIP> \ | grep -iE '^(passwordauthentication|kbdinteractiveauthentication)' done
TMP="$(mktemp)" printf '%s\n' 'labuser01 ALL=(root) NOPASSWD: /usr/local/sbin/ch4-ssh-log ""' > "$TMP" sudo visudo -cf "$TMP" sudo install -o root -g root -m 0440 "$TMP" /etc/sudoers.d/10-ch4-labuser01 rm -f "$TMP" sudo visudo -c
よくある落とし穴:
sudo sshd -tとreloadを別の行に書く → 検査が失敗しても壊れた設定が適用される。必ず&&でつなぐ-Cを付けずにsshd -Tを打つ →Matchが評価されず、実際と違う値が出る- snapshot を「壊した後」に取る → 自己剥奪ルールごと復元されて詰む
答えを見る(この章の設計を 1 枚に)
A ① 実効値を記録し、鍵で入れることを証明する
└─ 触る前に「戻れること」を確かめる
B ② snapshot(★まだ何も変更していない)
└─ 取得点が、何が残り何が消えるかを決める
C ③④ 演習ユーザ・鍵・最小権限 sudo
└─ ラッパー+"" で引数を構造的に通さない
D ⑤ D-1: 鍵なしで閉じる → 締め出し
D-2: 検査を通る !ALL → sudo が死ぬ
└─ 5 経路を潰して「内側に手段が無い」を確定させる
E ⑥ qm rollback(唯一の出口)
└─ B の取得点が正しかったからこそ確実に戻る
F ⑦ 鍵を先に配ってから同じ設定を適用
└─ 中身は同じ。違うのは順番だけ
この章で覚えること:
- sudoers にコマンド名だけ書くのは、root を渡すのと変わらないことがある
visudoが通ることは、安全であることを意味しない- sudoers は後勝ち、sshd_config は先勝ち。逆である
- 反映確認は
systemctlではなくsshd -T -Cの実効値で - snapshot は「壊す前」に取る。取得点がすべてを決める
- 安全な設定という概念は無い。あるのは安全な順序だけ
次の章へ
ここまでで、サービスを常駐させ(Ch1)、ログで追い(Ch2)、権限を設計し(Ch3)、それを安全に固める(Ch4)ところまで来ました。次の Chapter 5 では、運用のもう 1 つの軸である容量を扱います。
- 稼働中の VM に仮想ディスクを追加し、ゲストが再起動なしに認識する様子を見る
- LVM を組み、マウントしたまま 8G から 15G へオンライン拡張する
- 「割り当てた容量」と「実際に消費した容量」が違うことを、物理層の数字で確かめる
本章で使った qm コマンドとスナップショットの考え方が、そのまま土台になります。
もっと知りたい方へ
sudo -l: いま自分に何が許可されているかを一覧します。権限を設計したら、対象ユーザ自身でこれを打って確認するのが確実ですvisudo -f <ファイル>: 直接編集する場合も、visudo経由なら保存時に構文検査が走ります。viで直接開くのは避けてくださいsshd -Tの全出力:-Cなしで打つと、いま有効な設定が数十行まとめて出ます。「自分が知らない設定が入っていないか」を棚卸しするのに使えますMatch Address/Match Group:Match User以外にも、接続元アドレスやグループで条件を切れます。「社内からはパスワード可、外からは鍵のみ」といった設計ができますAuthorizedKeysCommand:authorized_keysをファイルではなく外部コマンドの出力から取得する方法です。鍵を中央管理したい規模になったときの選択肢ですqm snapshot --vmstate 1: RAM も含めたスナップショットです。取得・復元に時間と容量がかかりますが、実行中のプロセスごと巻き戻せます- fsfreeze と整合性: 稼働中のディスクスナップショットは「電源を抜いた瞬間」と同等です。QEMU Guest Agent の fsfreeze を使うと、ファイルシステムを一時停止してから取得でき、整合性が上がります