22
17

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

わかるようでわからないssh接続について

22
Posted at

この記事の使い方

先に問題を出します。

  1. 鍵をまだ交換していないのに、どうやって暗号化を始めているのですか
  2. 公開鍵認証で、サーバに送っているものは何ですか
  3. ssh を3回叩いたら、TCP 接続は3本になりますか
  4. あなたの秘密鍵が盗まれたら、どこまでやられますか

全部説明できるなら、この記事に書いてあることは既にご存知です。ブラウザを閉じてください。

私は 1 から答えられませんでした。
~/.ssh/configHost を書いて、鍵を作って、.pub をサーバに置いて、繋がらなければパーミッションを 600 にする。
手は覚えているのに、何が起きているのかは知らない。

この記事は、上の4問に順番に答えていきます。
各見出しがそのまま設問なので、答えられる問いは飛ばしてください。

なお、TCP については次の3点だけを前提にします。

  • 通信を始める前に、「接続」を確立する手順がある
  • サーバはポート番号を決めて待ち受け、クライアントがそこへ繋ぐ
  • 確立した接続は、順序の保たれた1本のバイト列である(Q3 で効いてきます)

読むために必要な、コマンドの前提

この記事では、ssh に何が起きているかを -v で覗きながら進めます。

ssh は普段、何も報告しません。繋がるか、繋がらないか。それだけです。
-v を付けると、内部の動きを標準エラー出力に垂れ流すようになります。-vv-vvv と増やすほど細かく出ます。
出力の各行は debug1: debug2: debug3: で始まっていて、この数字が「何個 v を付けたときに出るか」に対応します。

また、ssh lab 'true' のようにコマンドを渡す形をよく使います。
ssh にコマンドを渡すと、ログインシェルを開く代わりにそれだけを実行して切断します。
true は「何もせず成功だけを返す」コマンドなので、接続して切るまでの最小の往復 を観測したいときに便利です。

以下の出力は、手元に立てた使い捨ての SSH サーバ(Docker の Alpine + sshd、LogLevel DEBUG3)に対して実際に叩いたものです。
クライアントの -vvv とサーバの docker logs を突き合わせています。

なお、ssh コマンド自体の詰まりどころ(~/.ssh/config の設定が効かない、鍵は合っているのに Permission denied、手元では動くのに CI で落ちる)は、この記事では扱いません。


Q1. 鍵をまだ交換していないのに、どうやって暗号化を始めているのですか

「鍵交換」と即答できた人は Q2 へ。

暗号化には鍵が要ります。
そして SSH の通信は暗号化されている、と説明されます。

でも、その鍵はどうやって相手と共有したのでしょうか。
鍵を送って渡したのなら、その「鍵を送る通信」自体は暗号化されていたのでしょうか。されていなかったなら、盗まれるのでは。

鶏と卵に見えるこの部分が、いちばん引っかかっていたところでした。

まず、いちばん最初に流れるものを直接見る

ssh使わずに、ただの TCP 接続として 22 番(この記事では 2222 番)に繋いでみます。

使うのは nc(netcat)です。
指定したホストとポートに TCP で繋いで、標準入力を送り、返ってきたものを標準出力に出す、それだけのコマンドです。

$ printf '' | nc -w 2 localhost 2222 | cat -v
SSH-2.0-OpenSSH_9.7^M
  • printf '' は空文字を送る、つまり こちらからは何も送らない という意味です
  • -w 2 は2秒待ったら諦める指定です
  • cat -v は、目に見えない制御文字を記号で表示させるためのものです。末尾の ^M は改行コード CR(\r)を表しています

結果、こちらが何も名乗っていないのに、サーバのほうから暗号化されていない文字列が返ってきました。

SSH-2.0-OpenSSH_9.7 は、「SSH プロトコルのバージョン 2.0 を喋る、OpenSSH 9.7 というソフトウェアです」という自己紹介です。
これを バナー と呼びます1

サーバは、繋いできた相手が誰かも確かめないうちに、自分の素性を平文で明かしているわけです。

次に来るものも、まだ平文

バナーは互いに送り合う決まりなので、こちらからも同じ形式で名乗ってやると、続きが見られます。

$ { printf 'SSH-2.0-MyOwnClient\r\n'; sleep 2; } | nc -w 3 localhost 2222 | xxd | head
00000000: 5353 482d 322e 302d 4f70 656e 5353 485f  SSH-2.0-OpenSSH_
00000010: 392e 370d 0a00 0004 5c07 147b 4ab8 f257  9.7.....\..{J..W
00000020: f753 d7a2 5875 d039 1f4d 5000 0001 3173  .S..Xu.9.MP...1s
00000030: 6e74 7275 7037 3631 7832 3535 3139 2d73  ntrup761x25519-s
00000040: 6861 3531 3240 6f70 656e 7373 682e 636f  ha512@openssh.co
00000050: 6d2c 6375 7276 6532 3535 3139 2d73 6861  m,curve25519-sha
00000060: 3235 362c 6375 7276 6532 3535 3139 2d73  256,curve25519-s
00000070: 6861 3235 3640 6c69 6273 7368 2e6f 7267  ha256@libssh.org
  • { printf ...; sleep 2; } は「適当なバナーを名乗ってから、2秒間そのまま待つ」という意味です。すぐ切ると続きが受け取れません
  • xxd は、受け取ったバイト列を16進数で表示するコマンドです

xxd の出力の読み方を補足します。
左端が先頭からの位置、真ん中が16進数の生データ、右端がそれを文字として解釈したもの です。
文字にならないバイトは . で表示されます。

注目するのは右端です。

ntrup761x25519-sha512@openssh.com,curve25519-sha256,curve25519-sha256@libssh.org

暗号アルゴリズムの名前が、そのまま読めています。

これは SSH2_MSG_KEXINIT というメッセージで、中身は「自分はこれらの暗号方式に対応しています」という一覧です。
サーバとクライアントが互いにこれを送り合い、共通して使えるものを選びます。

そしてこの一覧のやり取りは、暗号化されていません。
当然で、まだどの暗号方式を使うかすら決まっていないからです。

平文で流れるのは、ここまでではありません。
このあと続く鍵交換のメッセージも、まだ平文です。
暗号化が始まるのは、そのやり取りが終わってからになります(切り替わる瞬間はこの節の最後で見ます)。

つまり「SSH 接続は暗号化されている」という説明は、接続の冒頭には当てはまらない ということでした。

交渉の結果

互いの一覧を突き合わせて何に決まったかは、ssh -vvv の出力にそのまま出ます。

debug1: kex: algorithm: sntrup761x25519-sha512@openssh.com
debug1: kex: host key algorithm: ssh-ed25519
debug1: kex: server->client cipher: chacha20-poly1305@openssh.com MAC: <implicit> compression: none
debug1: kex: client->server cipher: chacha20-poly1305@openssh.com MAC: <implicit> compression: none

kex は key exchange(鍵交換)の略です。
1行目が鍵の作り方、2行目がサーバの身元確認に使う方式、3行目と4行目が実際にデータを暗号化する方式です。
行き(client→server)と帰り(server→client)で別々に決まります。

面白いのは、ここで選ばれた sntrup761x25519-sha512 が、クライアントの第一希望ではなかったことです。
手元の OpenSSH 10.3 が最初に推していたのは mlkem768x25519-sha256 でした2
ところが相手のサーバは OpenSSH 9.7 で、その方式をまだ知りません。
そこで、両者が共通して持っているものの中でいちばん優先度の高いものに落ち着いています。

古いサーバに繋いでも新しいサーバに繋いでも、こちらが何も指定しなくても通るのは、この摺り合わせが毎回走っているからです。

答え:共通秘密は送っていない

ここまでで決まったのは「どの方式を使うか」であって、鍵そのものではありません。
このあとの 鍵交換 で、双方の手元に 共通秘密 という同じ値ができ、そこから通信を暗号化する鍵が導出されます。

冒頭の疑問への答えは、この手順の性質にあります。
共通秘密は送られません。互いの手元で、別々に同じ値が計算されます。

骨格を書くと、こうです。

  1. クライアントとサーバが、それぞれ自分だけが知る値を用意する(これは外に出さない)
  2. その値をもとに作った、公開してよいデータ を相手に送る
  3. 受け取った側は、届いたデータと、自分だけが知る値を組み合わせて計算する
  4. 双方の手元に、まったく同じ値(共通秘密)ができあがる

2 で実際に何が流れるかは、選ばれた方式によって違います。
今回選ばれた sntrup761x25519-sha512 の場合、クライアントは公開鍵を送り、サーバはそれを使って作った暗号文を返す、という非対称な形になります3

方式が違っても、共通しているのは 4 の性質です。
できあがった共通秘密は、一度も回線を流れません。
流れたのは、それを作るための公開データだけです。

そして実際に通信を暗号化する鍵は、この共通秘密そのものでもありません。
共通秘密から、送信方向と受信方向それぞれの暗号鍵や初期化ベクトルが導出されます4

だから、盗聴者が最初から最後まで全パケットを記録していても、共通秘密には届きません。

この計算が終わると、双方が「では、ここから暗号化に切り替えます」という合図を送り合います。

debug1: SSH2_MSG_NEWKEYS sent
debug1: SSH2_MSG_NEWKEYS received

NEWKEYS そのものは切り替え前の状態で送られます。
送った側は送信方向を、受け取った側は受信方向を、それぞれ新しい鍵に切り替える。
双方が送り終えた時点で、以降のパケットは全部暗号化されます5

なお、2行目の host key algorithm は、サーバが自分の身元を証明するための鍵(ホスト鍵)の方式です。
サーバは鍵交換の途中でこの鍵を使って署名を返し、クライアントは受け取った公開鍵を ~/.ssh/known_hosts の記録と突き合わせます。
初回だけは記録がないので「本当に繋いでいいか」と聞かれ、yes と答えたものが以後の基準になる。
あの警告が出たときに ssh-keygen -R で消していいのは、サーバを作り直した心当たりがあるときだけ、というのはこの仕組みのためです。

ここまでで身元を証明したのは、サーバ側だけです。
クライアントもソフトウェア名は名乗っていますが(Q1 の冒頭で見た identification string です)、それが誰なのかはまだ示していません
ユーザ名も、パスワードも、鍵も出てきていません。
それは次の段階の話になります。


Q2. 公開鍵認証で、サーバに送っているものは何ですか

「公開鍵と署名」と答えた人は Q3 へ。

通り道ができて、相手の身元も(前回と同じという意味では)確かめました。
ここでようやく、こちらが名乗る番です。

「秘密鍵を送っているわけではない」とは、なんとなく聞いたことがありました。
では何を送っているのか。-vvv を追うとわかります。

debug1: Authentications that can continue: publickey,password,keyboard-interactive
debug1: Next authentication method: publickey
debug1: Offering public key: keys/lab_ed25519 ED25519 SHA256:LtEK7xnJ7...
debug2: we sent a publickey packet, wait for reply
debug3: receive packet: type 60
debug1: Server accepts key: keys/lab_ed25519 ED25519 SHA256:LtEK7xnJ7...
debug3: sign_and_send_pubkey: signing using ssh-ed25519 SHA256:LtEK7xnJ7...
debug3: receive packet: type 52
Authenticated to localhost ([::1]:2222) using "publickey".

1行ずつ読みます。

  • Authentications that can continue: は、サーバが「うちが受け付ける認証方式はこれです」と提示してきた一覧です。ここでは公開鍵、パスワード、対話式の3つ
  • Next authentication method: publickey で、クライアントが公開鍵認証を選びました
  • Offering public key: で、公開鍵を提示します。この時点ではまだ署名していません
  • receive packet: type 60 は、サーバからの返事です。type 60 は「その鍵なら受け付けます」を意味するメッセージ番号6
  • それを見てから sign_and_send_pubkey: signing で、はじめて署名を作って送ります
  • receive packet: type 52 が「認証成功」の返事です

つまり、ここでは二段階を踏んでいます。

  1. 「この鍵、使えますか」と公開鍵だけ見せて聞く
  2. 「使えます」と言われてから、署名を作って送る

ただしこれは仕様上の義務ではなく、OpenSSH がそうしているだけです。
RFC は最初から署名付きで送ることも認めています7

では、なぜ OpenSSH は問い合わせを挟むのか。
クライアントが鍵を1つに決められないからです。
サーバは「この鍵を出せ」と教えてくれないので、手元にある候補を順に提示して、当たるまで試します。
毎回いきなり署名を作っていたら、パスフレーズ付きの鍵ではそのたびに入力を求められてしまいます。
先に「その鍵で合っているか」だけ確かめれば、無駄が省けます。

サーバ側のログにも、同じ鍵が二度照合された跡が残っています。

$ docker logs ssh-lab 2>&1 | grep -E 'Accepted|Postponed'
Accepted key ED25519 SHA256:LtEK7xnJ7... found at /home/demo/.ssh/authorized_keys:1
Postponed publickey for demo from 192.168.215.1 port 51506 ssh2 [preauth]
Accepted key ED25519 SHA256:LtEK7xnJ7... found at /home/demo/.ssh/authorized_keys:1
Accepted publickey for demo from 192.168.215.1 port 51506 ssh2: ED25519 SHA256:LtEK7xnJ7...

Postponed(保留)が1回目、Accepted publickey が2回目です。

答えは「公開鍵と、署名」です。
署名付きのリクエストにも公開鍵は入っています8。サーバはそれを authorized_keys と照合し、署名の検証にも使うので、当然です。

送られないのは 秘密鍵 です。
署名は、その接続の鍵交換で決まった値(セッション識別子)を含むデータに対して手元で作られ、秘密鍵そのものはマシンから出ません。

だから厳しくする理由が、両者で違った

秘密鍵が本当に渡っていないことは、サーバ側のファイルを見ると納得できます。

authorized_keys は、そのユーザのログインを許す公開鍵を並べたファイルです。
これのパーミッションを、誰でも読める 644 にしてみます。

$ docker exec ssh-lab chmod 644 /home/demo/.ssh/authorized_keys
$ ssh lab 'echo STILL_OK'
STILL_OK

そのまま通ります。
中身が公開鍵だけで、他人に読まれて困るものが入っていないからです。

一方、手元の秘密鍵を 644 にすると、ssh はその鍵を読むことを拒否します。

$ chmod 644 keys/loose && ssh -i keys/loose demo@localhost 'echo GOT_IN'
Permissions 0644 for 'keys/loose' are too open.
This private key will be ignored.
demo@localhost: Permission denied (publickey,password,keyboard-interactive).

つまり、守りたいものが違います。

  • 秘密鍵は「読まれない」ことが要る。他人に読まれたら、その人があなたとして署名できます。ssh が見ているのは 600 という数字ではなく、他ユーザーからアクセスできるかどうかです9。400 でも通ります
  • authorized_keys は「書き換えられない」ことが要る。読まれても認証は破られませんが、行を1つ足されたら他人の鍵で入られます。sshd はこのファイル、~/.ssh、ホームディレクトリのいずれかが他ユーザーから書き込み可能だと、StrictModes no にしない限り認証を拒みます10

「SSH がうまくいかないときは 600 にする」と丸暗記していましたが、片方は読み取り、片方は書き込みの話でした。


Q3. ssh を3回叩いたら、TCP 接続は3本になりますか

「設定次第で1本」と答えた人は Q4 へ。

認証が終わったあとの -vvv に、それまで出てこなかった単語が現れます。

debug1: channel 0: new session [client-session]
debug2: channel 0: send open
debug1: Sending environment.
debug1: Sending command: echo REMOTE_OK; id; tty
debug2: exec request accepted on channel 0
debug2: channel 0: rcvd eof
debug1: client_input_channel_req: channel 0 rtype exit-status

チャネル です。

TCP が運んでくれるのは、順序の保たれた1本のバイト列だけです。
「ここまでがセッション1の出力、ここからがセッション2」という区切りは、TCP の側にはありません。
複数の会話を1本の接続に載せたいなら、その区切りは SSH が自分で作るしかない。
それがチャネルです。

認証が済んだあとの SSH は、暗号化された1本の通り道の中に、論理的な回線を複数開けます
数に上限はありますが、それはチャネル全体ではなく、シェルやサブシステムのセッションにかかります(MaxSessions、OpenSSH の既定は 10)。ポートフォワーディングはこの数に含まれません11

上の出力を追うと、こうなっています。

  1. channel 0: new session でチャネルを1本開く
  2. Sending environment で環境変数を送る
  3. Sending command でコマンドの実行を頼む
  4. rcvd eof で出力が終わったことを受け取る
  5. exit-status で終了コードを受け取る

2 から 5 は全部、「チャネル 0 に対する要求」です。
番号が振られているということは、1本とは限らないということでもあります。

同じ通り道に、3つのセッションを載せる

ssh には、一度張った接続を使い回す機能があります。
~/.ssh/config の該当ホストのブロックに、三行足します。

    ControlMaster auto
    ControlPath /tmp/ssh-lab/cm-%r@%h:%p.sock
    ControlPersist 60s
  • ControlMaster auto は「まだ接続がなければ張る、あればそれに相乗りする」という指定です
  • ControlPath は、その接続を共有するための Unix ドメインソケットの置き場所です。%r はユーザ名、%h はホスト名、%p はポート番号に置き換わります
  • ControlPersist 60s は「最後の利用から60秒は接続を維持しておく」という指定です

この状態で、3つの ssh を同時に走らせます。

$ for i in 1 2 3; do ssh lab "echo session-$i started; sleep 4" & done
session-2 started
session-3 started
session-1 started

それぞれ4秒眠るコマンドなので、3つが同時に走っている状態が4秒続きます。
その間に、手元の TCP 接続を数えます。

$ netstat -an -p tcp | grep 2222 | grep ESTABLISHED
tcp6       0      0  ::1.2222               ::1.58491              ESTABLISHED
tcp6       0      0  ::1.58491              ::1.2222               ESTABLISHED

netstat -an は、いま張られている接続の一覧を出すコマンドです。
ESTABLISHED は接続中の状態を指します。

2行ありますが、これは同じ1本の接続を両端から見ているだけです。
今回はサーバが同じマシンの中(ループバック)にあるので、「2222 番から見た図」と「58491 番から見た図」の両方が出ています。
組は ::1.58491::1.2222 の1組だけ。つまり 接続は1本 です。

サーバ側も見てみます。

$ docker exec ssh-lab ps -o pid,ppid,user,args
PID   PPID  USER     COMMAND
    1     0 root     sshd: /usr/sbin/sshd -D -e [listener]
   36     1 root     sshd: demo [priv]
   38    36 demo     sshd: demo@notty
   40    38 demo     sleep 4
   41    38 demo     sleep 4
   42    38 demo     sleep 4

ps はプロセスの一覧です。PPID は親プロセスの番号を指します。

読むと、PID 38 の sshd(demo としてログイン中のプロセス)が1つあり、その下に sleep 4 が3つぶら下がっています。
接続を受け付けている sshd プロセスは、3つではなく1つです。

答え:設定次第で1本になります。

一接続に一ログイン、だと思い込んでいました。
実際には、SSH は暗号化された1本の通り道の上で、複数の独立した会話を多重化するプロトコル です。
シェルのセッションは、その会話の一種類にすぎません。

速さにも出る

同じコマンドを3回、毎回接続し直した場合と、相乗りした場合で計測しました。

# 毎回ハンドシェイクする場合
real 0.13
real 0.13
real 0.12

# 既存の接続に相乗りする場合
real 0.05
real 0.01
real 0.01

1回目の 0.05 秒には接続を張る分が含まれていますが、2回目以降は 0.01 秒。毎回張り直す 0.13 秒とは10倍以上の開きがあります。
手元のコンテナ相手なので、ネットワークの遅延はほぼゼロです。
それでもこれだけ違うのは、差分がまるごと、Q1 の鍵交換と Q2 の認証にかかった時間だからです。

これが海の向こうのサーバだと、そのやり取り一往復ごとに実際の通信時間が乗ります。
git push を何度も繰り返すとき、Ansible で何十個もコマンドを流すときに効くのはここです。

ついでに、curl との違いもここにあります

curl https://example.com も SSH も、暗号化された通信です。
公開鍵暗号を使うところも、途中で共通秘密を作ってそこから暗号鍵を導出するところも似ています。
違うのは、その先で何を流すかです。

多重化そのものは HTTP にもあります。HTTP/2 は1本の接続に複数のストリームを流せます12
違うのは、その1本ずつが何を運ぶかです。

HTTP のストリームが運ぶのは、要求と応答の対です。
リクエストを一つ投げ、レスポンスを受け取り、それで一区切り。
サーバは原則として前回のことを覚えていないので、状態は Cookie やトークンとして毎回運び直されます。

SSH の session チャネルが運ぶのは、向こうで走り続けるプロセスとのやり取りです。
対話シェルで cd /var/log してから ls すれば、その ls/var/log で走る。
シェルのプロセスが生きたまま、そこにチャネルがつながっているからです。

そして SSH のチャネルはただのバイト列の通り道なので、載せるものを選びません。
端末があるかどうかさえ、チャネルへの要求の一つでしかありません。

$ ssh lab 'echo REMOTE_OK; id; tty'
REMOTE_OK
uid=1000(demo) gid=1000(demo) groups=1000(demo)
not a tty

not a tty と出るのは、コマンドを直接渡した場合にクライアントが疑似端末を要求しないからです。
対話ログインのときは pty-req というチャネル要求が追加で飛び、向こうに端末が割り当てられます。
ssh -t を付けたくなる場面があるのは、この要求を明示的に出させるためでした。


チャネルに乗るのは、シェルだけではない

その一例が sftp です。

lscdput が使えて、ftp によく似た操作感です。
だから「FTP という別のプロトコルを、SSH で暗号化したもの」だと思っていました。

そうではありませんでした。

sftp にも -v が付きます。
何をしているか見てみます。

$ echo ls | sftp -v -P 2222 demo@localhost 2>&1 | grep -E 'channel 0: new|Sending'
debug1: channel 0: new session [client-session]
debug1: Sending environment.
debug1: Sending subsystem: sftp

echo ls | は、対話プロンプトに ls と打つ代わりです。-P 2222 は接続先ポートの指定で、ssh-p と違って大文字です。)

ここまで見てきたチャネルが、そのまま出てきています。
sftp は 22 番に普通の SSH 接続をして、チャネルを1本開き、そこで subsystem という種類の要求 を出しているだけです。

サブシステムとは何か

さきほどは、チャネルに対して「コマンドを実行しろ」(exec)という要求を出していました。
要求にはもう一種類あって、それが subsystem です。

exec が「このコマンドラインを実行しろ」なのに対し、subsystem は「この名前で登録されているプログラムを起動しろ」という頼み方をします。
名前と実体の対応は、サーバ側の sshd_config に書いてあります。

Subsystem sftp /usr/lib/ssh/sftp-server

クライアントが sftp という名前を送ると、サーバはこの行を引いて /usr/lib/ssh/sftp-server を起動します。
そのあとは、開いたチャネルの中で、クライアントの sftp コマンドとサーバの sftp-server がファイル操作用のメッセージをやり取りします。

つまり SFTP は、SSH のチャネルの中で動くファイル操作プロトコル です。
FTP とは何の関係もありませんし、独自のポートも持ちません。

sftp を使うのに専用のファイアウォール設定が要らないのは、22 番しか使っていないからでした。
逆に言えば、サーバ側に Subsystem sftp の設定があり、鍵に command= などの制限がかかっていなければ、SSH で入れる相手には sftp も使えます。

意外だったのは、SFTP の仕様が RFC になっていないことです。
draft-ietf-secsh-filexfer は 2007 年に失効した Internet-Draft のまま止まっていて、いま動いているのは事実上 OpenSSH の実装が基準になっています13

ついでに scp も見ておくと、こうなっていました。

$ scp -v /tmp/hello.txt demo@localhost:/tmp/ 2>&1 | grep -E 'Executing|Sending subsystem'
Executing: program /usr/bin/ssh host localhost, user demo, command sftp
debug1: Sending subsystem: sftp

scp と打っているのに sftp を使っています。
OpenSSH 9.0 で、旧来の scp/rcp プロトコルが SFTP に置き換わりました14
scp は「リモートでシェルを起動して独自プロトコルで喋る」実装だったので、ファイル名に含まれるシェルメタ文字が展開されてしまう問題を抱えていたのですが、それが構造的に消えています。


Q4. あなたの秘密鍵が盗まれたら、どこまでやられますか

この問いに即答できる人は、たぶんこの記事を読んでいません。

ここまでで、SSH の強いところは出そろいました。
秘密がサーバに渡らないこと(Q2)、1本の通り道に何でも載せられること(Q3)、22 番さえ通れば追加の穴が要らないこと。

弱いところも、同じ性質の裏返しとして出てきます。

先に答えを書きます。
秘密鍵を盗まれると、その鍵が登録されているサーバに、あなたとして認証されます
制限をかけていない普通のログイン鍵なら、そのアカウントの権限でコマンドを実行されます。
どこまでやれるかは鍵に付けた制限次第です。後述する command=restrict があればその範囲に収まりますし、接続元を縛る from= を書いてあれば、鍵を持っているだけでは認証そのものが通りません。

それが踏み台の鍵なら、後述するとおり、そこからネットワーク的に接続を試せる範囲まで手が伸びます。

厄介なのは被害の大きさより、気づけないことと、止めにくいこと のほうです。

誰がどのサーバに入れるのかを、一箇所で管理できない

「入れる人」の一覧は、各サーバの authorized_keys に書かれています。
サーバが 50 台あれば、50 個のファイルに分散しています。

つまり、退職者の鍵を全台から消し切ったかどうかを確かめる手段が、SSH というプロトコルの側にありません。
構成管理ツールで配っているなら追えますが、それは SSH の機能ではなく、外側の運用でカバーしているということです。

鍵が漏れたときも同じで、「その鍵がどこに登録されているか」を SSH に問い合わせる方法がありません。

対策としては、SSH 証明書 という仕組みがあります。
HTTPS の証明書と似た考え方で、CA(認証局)の役割をする鍵を1つ用意し、それでユーザの公開鍵に有効期限付きの署名をします。
サーバ側は「この CA が署名した鍵なら通す」と設定しておけば、authorized_keys を配って回る必要がなくなり、期限切れで自然に失効します。

誰が何をしたかは、SSH には残らない

認証が終わったあとに流れるのは、暗号化されたバイト列です。
サーバ側のログに残るのは「demo が publickey で入った」までで、そのあとシェルで何を叩いたかは記録されません。

Q2 で見たサーバ側ログを思い出してください。

Accepted publickey for demo from 192.168.215.1 port 51506 ssh2: ED25519 SHA256:LtEK7xnJ7...

入ったことは残ります。何をしたかは残りません。

シェルの履歴(.bash_history)に頼ることになりますが、それは操作した本人が消せますし、そもそも書かれないコマンドもあります。
監査が必要な環境で「SSH で入って作業する」を続けるなら、記録を取る仕組みを別に用意する必要があります。

踏み台は、単一障害点であると同時に単一侵入点でもある

ssh -L で踏み台越しに DB へ繋げるのは、踏み台から接続できる先には全部届くからです。
これは裏返すと、踏み台を取られた攻撃者も、そこから接続を試せる範囲すべてに手が届く ということです。
届くのはネットワークとしての到達性までで、その先の DB やアプリの認証は別に破る必要があります。それでも、外からは触れないはずのものが攻撃対象に変わります。

これがいちばんはっきり出るのが、エージェント転送です。

ssh-agent は、秘密鍵をメモリに預けておく常駐プロセスです。
パスフレーズを毎回入力しなくて済むように、ssh-add で鍵を預けておくと、署名だけを代わりにやってくれます。

-A を付けて接続すると、この agent への窓口が接続先にも用意されます。
接続先からさらに別のサーバへ SSH するとき、手元の鍵で認証できるようになる機能です。多段接続で重宝します。

何が起きているか、接続先の中から見てみます。

$ ssh -A lab 'echo $SSH_AUTH_SOCK; ssh-add -l'
/tmp/ssh-XXXXdCJMBB/agent.143
3072 SHA256:xxxxxxxx... hirofumi@MacBook-Air.local (RSA)
4096 SHA256:xxxxxxxx... hirofumi@MacBook-Air.local (RSA)
256 SHA256:xxxxxxxx... ssh-lab-demo (ED25519)

$SSH_AUTH_SOCK は agent への窓口の場所を指す環境変数で、リモート側にも設定されています。
そして ssh-add -lリモートで 実行すると、手元のマシンの agent が抱えている鍵が全部並びました。

秘密鍵そのものがコピーされたわけではありません。
転送されたのは「署名してくれ」と頼むための窓口だけです。

それでも、接続している間はその窓口を通じて署名させられます。対象は、確認を求める設定(ssh-add -c)や宛先の制約、FIDO キーのタッチ要求がかかっていない鍵です。
そのサーバの root を取られていれば、あなたが繋いでいる間だけ、あなたとして別のサーバにログインできてしまいます。

多段接続がしたいだけなら、ProxyJump-J)を使ってください。
踏み台を経由するけれど、鍵の窓口は渡さない、という繋ぎ方ができます。

Host internal
    HostName 10.0.1.5
    ProxyJump bastion

接続が切れると、作業も落ちる

Q3 で見たとおり、SSH は接続そのものが状態を持ちます。
便利な反面、回線が切れると、その状態ごと終わってしまうことがあります。

一時的な通信断なら TCP 接続が持ちこたえることもありますが、実際に切断されると、長時間のバッチを ssh の先で直接走らせている場合はプロセスや PTY の状態次第で道連れになります。
tmuxscreen を挟んだり、nohup を付けたりするのは、状態を SSH のセッションではなくサーバ側に残すためです。

鍵1本あたりの権限を絞る

鍵は「入れるか入れないか」だけのものではありません。
1本ごとに、入ったあと何ができるかを縛れます。
authorized_keys の行頭に、オプションを書きます。

restrict,command="/bin/date" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA... 
  • command="..." は、この鍵で入ってきた接続では、クライアントが何を要求してもこのコマンドしか実行しない という指定です
  • restrict は、ポートフォワード、エージェント転送、X11 転送、疑似端末の割り当て、~/.ssh/rc の実行を禁止します15

実際に試すと、こうなります。

$ ssh lab 'cat /etc/shadow'
Fri Aug  7 05:55:51 UTC 2026

/etc/shadow の中身を要求したのに、日付が返ってきました。
command="/bin/date" が効いているので、こちらが何と言おうと date しか動きません。

ssh -L によるポート転送も拒まれます。

$ docker logs ssh-lab | grep refused
refused local port forward: originator ::1 port 59112, target 127.0.0.1 port 8080

デプロイ用やバックアップ用の鍵を、とりあえず普通のログイン鍵として置いていないでしょうか。
その鍵が漏れたら、シェルが取られます。
command=restrict を書いておけば、被害をその強制コマンドが持つ権限と機能の範囲に抑えられます(何度でも実行できますし、そのコマンド自身に穴があればそこは残ります)。

用途ごとに鍵を分けておけば、1本漏れたときの被害がその用途の中に収まります。

ポート番号を変えても、防御にはならない

最後に、よくある話を一つ。

22 番から別のポートに変えると、ログの雑音は確かに減ります。
自動化された総当たりの多くは 22 番しか見ていないからです。

ただし、本気で狙ってくる相手はポートスキャンをするので、隠したことにはなりません。
効くのは次の2つです。

  • パスワード認証を無効にする(総当たりの標的そのものを消す)。ただし PasswordAuthentication no だけでは足りません。KbdInteractiveAuthentication が別に存在し、既定が yes で、PAM 経由でパスワードを聞けてしまうことがあります
PasswordAuthentication no
KbdInteractiveAuthentication no
  • そもそも 22 番をインターネットに晒さない(VPN の内側に置く、踏み台を経由させる)

こうして並べると、SSM Session Manager や Teleport のようなものが何を解こうとしているのかも見えてきます。
どれも「22 番を開けない」「鍵ではなく IAM やシングルサインオンで認可する」「セッションを記録する」という、いま挙げた点に対応しています。ただし記録できる範囲には限りがあり、たとえば Session Manager でも、その上を通した SSH やポートフォワーディングの中身までは残りません。
SSH をやめる話ではなく、SSH が持っていない管理面を外から足す話です。


おわりに

4問を追いかけて残ったのは、「安全にリモート操作するためのプロトコル」という一文が、実は三つの別々の仕事を畳んでいるという感触でした。

  1. 暗号化された管を掘る(鍵交換)
  2. 管の両端が誰なのかを確かめる(ホスト鍵とユーザ認証)
  3. その管の中で複数の会話を同時に流す(チャネル)

RFC が 4252(認証)、4253(トランスポート)、4254(コネクション)と分かれているのも、この三層に対応しています。

そして「SSH でできること」として個別に覚えていたもの、つまりシェル、SFTP、scp、ポートフォワーディング、エージェント転送は、全部 3 の上に乗った別々のチャネルでした。
覚えることが多いと感じていたのは、同じものの応用を別々の機能として暗記していたからだと思います。

参考

  1. RFC 4253 §4.2 での呼び名は identification string です。認証時にサーバが表示する SSH_MSG_USERAUTH_BANNER/etc/issue.net の類)とは別物なので、厳密に書くときは区別が要ります。この identification string はあとで鍵交換ハッシュの材料にも入るので、単なる挨拶ではなく改竄検知の対象にもなっています。

  2. OpenSSH 10.0 リリースノート「ssh(1): the hybrid post-quantum algorithm mlkem768x25519-sha256 is now used by default for key agreement.」。いま暗号文を記録しておいて将来の量子計算機で復号する、という攻撃を想定した措置です。

  3. RFC 9941 §3。クライアントが送る Q_C は「the 1158-byte public key output from the key generator of sntrup761」と 32 バイトの X25519 公開値の連結、サーバが返す Q_S は「the 1039-byte ciphertext output from the key encapsulation mechanism of sntrup761」と 32 バイトの X25519 公開値の連結です。

  4. RFC 4253 §7.2。「The key exchange produces two values: a shared secret K, and an exchange hash H.」「Encryption keys MUST be computed as HASH, of a known value and K」。たとえば client to server 方向の暗号鍵は HASH(K || H || "C" || session_id) として作られます。

  5. RFC 4253 §7.3。「This message is sent with the old keys and algorithms. All messages sent after this message MUST use the new keys and algorithms.」

  6. RFC 4252 §7。「The server MUST respond to this message with either SSH_MSG_USERAUTH_FAILURE or with the following: byte SSH_MSG_USERAUTH_PK_OK ...」。type 60SSH_MSG_USERAUTH_PK_OKtype 52SSH_MSG_USERAUTH_SUCCESS です。

  7. RFC 4252 §7「The client MAY send the signature directly without first verifying whether the key is acceptable.」

  8. RFC 4252 §7。署名付きリクエストのフィールドは SSH_MSG_USERAUTH_REQUEST、ユーザ名、サービス名、"publickey"TRUE、公開鍵アルゴリズム名、public key to be used for authenticationsignature の順です。

  9. ssh(1)「These files contain sensitive data and should be readable by the user but not accessible by others (read/write/execute). ssh will simply ignore a private key file if it is accessible by others.」

  10. sshd(8)「If this file, the ~/.ssh directory, or the user's home directory are writable by other users, then the file could be modified or replaced by unauthorized users. In this case, sshd will not allow it to be used unless the StrictModes option has been set to "no".」

  11. sshd_config(5)「Specifies the maximum number of open shell, login or subsystem (e.g. sftp) sessions permitted per network connection.」「setting it to 0 will prevent all shell, login and subsystem sessions while still permitting forwarding.」
    その1本1本がチャネルで、channel 0 のように番号が振られます。

  12. RFC 9113 §5。HTTP/2 のストリームは1接続上で多重化され、独立して流れます。

  13. この draft は 13 版まで進んだ末に 2007 年に失効しましたが、OpenSSH が実装しているのは 02 版にあたる revision 3 です。OpenSSH の PROTOCOL に「Note that OpenSSH's sftp and sftp-server implement revision 3 of the SSH filexfer protocol」「Newer versions of the draft will not be supported」とあります。

  14. OpenSSH 9.0 リリースノート「This release switches scp(1) from using the legacy scp/rcp protocol to using the SFTP protocol by default.」。互換性の問題が出たときは -O で旧挙動に戻せます。

  15. sshd(8)「Enable all restrictions, i.e. disable port, agent and X11 forwarding, as well as disabling PTY allocation and execution of ~/.ssh/rc. If any future restriction capabilities are added to authorized_keys files, they will be included in this set.」。コマンド実行そのもの(shell / exec / subsystem)は止めませんsftp を塞ぎたい場合も restrict だけでは足りず、上の command= のように実行内容を固定する必要があります。

22
17
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
22
17

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?