本文的使用方式

先出幾個問題。

  1. 還沒交換金鑰,怎麼就開始加密了?
  2. 在公開金鑰認證中,傳給伺服器的是什麼?
  3. ssh 按 3 次,TCP 連線會變成 3 條嗎?
  4. 如果你的私密金鑰被偷了,會被搞到什麼程度?

如果你都能回答,代表這篇文章寫的內容你其實已經知道了。請直接關掉瀏覽器。

我連第 1 題都答不上來。
我會在 ~/.ssh/config 裡寫 Host,會產生金鑰,會把 .pub 放到伺服器上,連不上就把權限改成 600。
手法記得住,但不知道背後到底發生了什麼。

這篇文章會依序回答上面 4 題。
每個標題本身就是題目,所以已經會的就跳過吧。

另外,關於 TCP,只先假設下面 3 點:

  • 在開始通訊前,會有一個建立「連線」的步驟
  • 伺服器會決定一個埠號並等候,客戶端連過去
  • 建立完成的連線,是一條有順序的位元組串流(第 3 題會用到)

閱讀所需的指令前提

本文會一邊用 -v 觀察 ssh 內部發生了什麼,一邊往下講。

ssh 平常不會回報什麼。能連上或不能連上,就這樣而已。
加上 -v 後,內部動作會直接寫到標準錯誤輸出;-vv-vvv 則會更詳細。
輸出的每一行都以 debug1: debug2: debug3: 開頭,數字對應的是要加幾個 v 才會出現。

另外,也常會用像 ssh lab 'true' 這種方式傳入指令。
把指令交給 ssh 時,它不會開啟登入 shell,而是直接執行那個指令後斷線。
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」。
這個自我介紹就叫做 banner1

也就是說,伺服器在還沒確認對方是誰之前,就先用明文把自己的身分說出來了。

接下來的內容,也還是明文

banner 會彼此交換,所以如果我也送出同樣格式的自我介紹,就能看到後續內容。

$ { 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  [email protected]
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  [email protected]
  • { printf ...; sleep 2; } 的意思是「先自稱一個隨便的 banner,然後等 2 秒不要立刻關掉」,不然後面的內容收不到
  • xxd 是把收到的位元組串流用十六進位顯示出來的指令

再補充一下 xxd 的讀法:
左邊是偏移量,中間是十六進位的原始資料,最右邊是把它當成字元後的結果
不能顯示成字元的位元組就會用 . 表示。

重點看右邊。

[email protected],curve25519-sha256,[email protected]

加密演算法的名稱直接就讀得到。

這是 SSH2_MSG_KEXINIT 這個訊息,內容是「我支援這些加密方式」的清單。
伺服器與客戶端會互相送出這份清單,然後選出雙方都能用的方式。

而且,這份清單的交換並沒有加密。
這是理所當然的,因為連要用哪種加密方式都還沒決定。

明文傳輸不只到這裡。
後面接著的金鑰交換訊息也還是明文。
等這整段交換結束後,才會開始加密;切換的瞬間會在本節最後看到。

也就是說,「SSH 連線是加密的」這句話,不適用於連線一開始的那段

協商結果

雙方清單比對後最後選了什麼,會直接出現在 ssh -vvv 的輸出裡。

debug1: kex: algorithm: [email protected]
debug1: kex: host key algorithm: ssh-ed25519
debug1: kex: server->client cipher: [email protected] MAC: <implicit> compression: none
debug1: kex: client->server cipher: [email protected] MAC: <implicit> compression: none

kex 是 key exchange,也就是金鑰交換的縮寫。
第 1 行是金鑰產生方式,第 2 行是驗證伺服器身分用的方式,第 3、4 行是實際資料要用的加密方式。
去程(client→server)和回程(server→client)可以分別決定。

有趣的是,這裡選到的 sntrup761x25519-sha512 並不是客戶端的第一志願。
我手上的 OpenSSH 10.3 一開始優先推薦的是 mlkem768x25519-sha2562
但對方伺服器是 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

另外,第二行的 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".

逐行看:

  • Authentications that can continue: 是伺服器列出「我接受哪些認證方式」
  • 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 還要先問一次?
因為客戶端可能有很多把金鑰,沒辦法直接知道該用哪一把。
伺服器也不會告訴你「請用這把」;所以客戶端只能把手上候選的金鑰逐一試過,直到對上為止。
如果每次都直接先簽章,對需要密碼保護的私密金鑰來說,可能會一直跳出密碼短語輸入。
先確認「是不是這把」,就可以避免白做工。

伺服器端的 log 也能看到同一把鑰匙被核對了兩次。

$ 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(暫緩)是第一次,Accepted publickey 是第二次。

答案是「公開金鑰和簽章」。
帶簽章的請求裡也會包含公開金鑰8。伺服器會把它拿去和 authorized_keys 比對,也會用它驗證簽章,所以這很合理。

沒有傳送的是 私密金鑰
簽章是在本機根據這次連線的金鑰交換所決定的值(session identifier)對資料計算出來的,而私密金鑰本身不會離開你的機器。

所以雙方對「嚴格權限」在意的理由不同

可以從伺服器端檔案看出,私密金鑰真的沒有被傳過去。

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 需要「不要被別人改掉」。就算被看到了也不會破壞認證,但如果被多加一行,別人的金鑰就能登入。sshd 會檢查這個檔案、~/.ssh、甚至家目錄是否可被其他使用者寫入;除非關掉 StrictModes,否則會拒絕認證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

channel

TCP 只會送一條有順序的位元組串流。
「到這裡是第 1 個 session 的輸出,從這裡開始是第 2 個」這種分界,TCP 本身沒有。
如果要把多個對話塞進同一條連線裡,那個切分必須由 SSH 自己做。
這個切分單位就是 channel。

認證之後的 SSH,會在這條加密通道裡,建立多條邏輯上的連線
數量有上限,但限制的是整體 session 數,不是 channel 本身;這個上限會套用在 shell 或 subsystem session 上(MaxSessions,OpenSSH 預設 10)。埠轉發不算在這個數量內11

上面的輸出可以這樣讀:

  1. channel 0: new session:開一條 channel
  2. Sending environment:傳環境變數
  3. Sending command:請求執行指令
  4. rcvd eof:收到輸出結束
  5. exit-status:收到結束碼

第 2 到第 5 點,全部都是針對 channel 0 的請求。
有編號就表示,這不一定只有一條。

把 3 個 session 載到同一條通道上

ssh 有共用已建立連線的功能。
~/.ssh/config 對應主機的區塊裡,加上三行:

    ControlMaster auto
    ControlPath /tmp/ssh-lab/cm-%r@%h:%p.sock
    ControlPersist 60s
  • ControlMaster auto 表示「如果還沒有連線就建立,有的話就共用」
  • ControlPath 是用來共享這條連線的 Unix domain socket 放置位置。%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 代表連線中。

雖然有兩行,但其實只是從兩端看同一條連線而已。
因為伺服器和本機在同一台機器上(loopback),所以會看到「從 2222 端看」和「從 58491 端看」兩個方向。
實際上的配對只有 ::1.58491::1.2222 這一組,也就是 只有 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 身分登入中的程序),底下掛了 3 個 sleep 4
真正接受連線的 sshd 程序只有 1 個,不是 3 個。

答案:看設定,確實可以只有 1 條。

我以前一直以為是一連線就對應一個登入。
其實 SSH 是在加密的單一通道上,多工承載多個獨立對話的協定
shell session 只是其中一種對話而已。

速度也會反映出來

同樣的指令執行 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 可以在一條連線上同時傳多條 stream12
不同的是,每條 stream 裡面承載的是什麼。

HTTP 的 stream 傳的是請求與回應的配對。
送一個 request,收一個 response,然後就結束一輪。
伺服器原則上不會記得上一次的內容,所以狀態通常要靠 Cookie 或 token 每次重新帶上。

SSH 的 session channel 傳的是和遠端持續運作的程序之間的互動。
在互動式 shell 裡 cd /var/log 之後再 ls,那個 ls 就是在 /var/log 之下執行。
因為 shell 程序還活著,channel 持續接在那個 session 上。

而且 SSH 的 channel 只是單純的位元組通道,所以能放的東西不只一種。
連有沒有終端機,都是 channel request 的一種。

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

會出現 not a tty,是因為直接傳指令時,客戶端不會主動要求 pseudo-terminal。
互動式登入時則會額外送出 pty-req 這種 channel request,讓對方配置終端機。
有時候會想加 ssh -t,就是為了明確送出這個請求。


會跑在 channel 上的不只有 shell

一個例子就是 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 不一樣,是大寫。)

這裡又看到了前面提過的 channel。
sftp 只是照常對 22 埠建立 SSH 連線、開一條 channel,然後在那條 channel 裡送出 subsystem 類型的請求。

什麼是子系統

前面我們對 channel 下的要求是「執行指令」(exec)。
其實還有另一種要求,就是 subsystem

exec 是「請執行這串命令列」,而 subsystem 是「請啟動這個名稱所對應的程式」。
名稱與實體的對應,會寫在伺服器端的 sshd_config 裡。

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

當客戶端送出 sftp 這個名稱時,伺服器就會查這一行,啟動 /usr/lib/ssh/sftp-server
之後在開啟的 channel 裡,客戶端的 sftp 指令與伺服器的 sftp-server 就會互相交換檔案操作訊息。

也就是說,SFTP 是跑在 SSH channel 裡的檔案操作協定
它跟 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 的作法是「在遠端啟動 shell,透過自訂協定溝通」,所以檔名裡如果有 shell metacharacter 可能會被展開,這類問題就出在結構設計本身;改成 SFTP 後,這個問題就從根本上消失了。


Q4. 如果你的私密金鑰被偷了,會被搞到什麼程度?

如果你能立刻回答這題,大概就不會在看這篇文章了。

到這裡,SSH 的強項已經都看過了:
秘密不會傳到伺服器上(Q2)、一條通道可以載很多東西(Q3)、只要 22 埠通就不需要額外開洞。

但弱點也正是這些特性的反面。

先直接回答。
如果私密金鑰被偷,攻擊者就能以那把金鑰所登記的身分,通過對應的伺服器驗證
若是沒有額外限制的一般登入金鑰,對方就能用那個帳號的權限執行指令。
實際能做到哪一步,取決於你替那把金鑰加了什麼限制。後面會提到的 command=restrict 能把範圍限制住;如果你有寫 from= 限制來源,那就算拿到金鑰,認證也不一定過得了。

如果那是跳板機用的金鑰,如下文所述,還會進一步碰到它能到達的網路範圍內的一切。

麻煩的不只是損害有多大,而是很難察覺,也很難即時阻止

無法在同一處管理誰可以登入哪些伺服器

「誰可以登入」是寫在各台伺服器的 authorized_keys 裡。
如果有 50 台伺服器,那就是分散在 50 個檔案裡。

也就是說,SSH 協定本身沒有辦法讓你確認「離職者的金鑰是否已經從所有伺服器上刪掉」。
如果你有透過組態管理工具發放,確實可以追蹤;但那是 SSH 以外的外部管理流程,不是協定本身的能力。

金鑰外洩時也是一樣,你沒辦法直接問 SSH:「這把金鑰被登記在哪些地方?」

對策之一是 SSH 憑證
概念上和 HTTPS 憑證很像:準備一把扮演 CA(憑證授權中心)角色的金鑰,對使用者的公開金鑰做有期限的簽章。
只要伺服器端設定成「接受這個 CA 簽過的金鑰」,就不需要一台一台散發 authorized_keys,到期後也會自然失效。

誰做了什麼,SSH 不會幫你保留

認證結束後,傳輸的就是加密的位元組串流。
伺服器 log 裡最多只會記到「demo 用 publickey 登入了」,之後在 shell 裡做了什麼,SSH 不會記。

回想一下 Q2 的伺服器端 log:

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

登入有記錄,做了什麼沒有。

你只能依賴 shell 歷史(.bash_history),但那東西可以被操作的人自己刪掉,而且本來就不保證每個指令都會寫進去。
如果你的環境需要稽核,還繼續用「直接 SSH 上去作業」,就必須另外建立記錄機制。

跳板機既是單點故障,也是單一入侵點

ssh -L 經由跳板機連到資料庫,是因為跳板機能連到的地方,基本上你也都能連到。
反過來說,如果跳板機被攻陷,攻擊者也能嘗試接觸它能到達的所有範圍
這只到「網路層能不能到」為止,資料庫或應用程式本身的認證還是得另外破解。即便如此,原本外部根本碰不到的東西,還是會被納入攻擊範圍。

這在 agent forwarding 上尤其明顯。

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... [email protected] (RSA)
4096 SHA256:xxxxxxxx... [email protected] (RSA)
256 SHA256:xxxxxxxx... ssh-lab-demo (ED25519)

$SSH_AUTH_SOCK 是指向 agent 入口的環境變數,而且在遠端也被設定好了。
遠端執行 ssh-add -l,就把本機 agent 裡持有的所有金鑰列出來了。

私密金鑰本身沒有被複製過去。
被轉送過去的只是「請替我簽章」的入口。

即便如此,只要連線還在,對方就能透過這個入口叫你的 agent 幫忙簽章。前提是那把金鑰沒有設定需要人工確認(ssh-add -c)、沒有額外限制目的地,也不是需要觸碰確認的 FIDO 金鑰。
如果那台伺服器拿到 root,就能在你連線期間,用你的身分再去登入別的伺服器。

如果你只是想要多段跳轉,請用 ProxyJump-J)。
這樣可以經由跳板機連線,但不會把金鑰入口一起交出去。

Host internal
    HostName 10.0.1.5
    ProxyJump bastion

連線一斷,工作也可能一起掉

如第 3 題所示,SSH 連線本身是有狀態的。
這很方便,但反過來說,網路一斷,整個狀態也可能一起結束。

如果只是短暫斷線,TCP 可能還撐得住;但真的中斷時,如果你是在 ssh 後面直接跑長時間批次工作,那些行程或 PTY 的狀態就可能一起消失。
所以才會在中間加 tmuxscreen,或加上 nohup,讓狀態留在伺服器端,而不是綁死在 SSH session 上。

把每把金鑰的權限縮小

金鑰不只是「能不能登入」而已。
每一把金鑰都可以限制登入後能做什麼。
這些選項寫在 authorized_keys 的每一行前面。

restrict,command="/bin/date" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA... 
  • command="..." 表示:這把金鑰登入時,不管客戶端要求什麼,只能執行這個指令
  • restrict 表示:禁止埠轉發、agent forwarding、X11 forwarding、pseudo-terminal 配置,以及執行 ~/.ssh/rc15

實際測試會變成這樣:

$ 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

部署用或備份用的金鑰,你是不是也先當成一般登入金鑰在用?
如果那把金鑰外洩,就代表 shell 會被拿走。
如果事先寫好 command=restrict,被害範圍就能被限制在那個強制指令所擁有的權限與功能內(可以重複執行,但如果那個指令本身有漏洞,問題還是會在)。

把用途分開配不同金鑰,單一金鑰外洩時,被害也就只會落在那個用途範圍內。

改埠號並不能防禦

最後再講一個常見說法。

把 SSH 從 22 埠換到其他埠,的確可以少掉一些雜訊。
很多自動化暴力破解只掃 22 埠。

但如果對方真的鎖定你,還是會先做 port scan,所以這不算隱藏。
真正有用的是下面兩件事:

  • 關掉密碼認證(直接移除暴力破解目標)。不過只設 PasswordAuthentication no 不夠,因為還可能另外有 KbdInteractiveAuthentication,預設值是 yes,有時候會透過 PAM 繼續要求你輸入密碼
PasswordAuthentication no
KbdInteractiveAuthentication no
  • 根本不要把 22 埠暴露在網際網路上(放進 VPN 內,或透過跳板機)

把這些放在一起看,也會比較能理解像 SSM Session Manager 或 Teleport 這類工具到底在解什麼問題。
它們都在處理「不要開 22 埠」「不要用金鑰做授權,而是用 IAM 或單一登入」「要能記錄 session」這些我前面提到的點。
不過可記錄的範圍有限,例如即使用 Session Manager,也不會把你透過它再跑的 SSH 或埠轉發內容完整留下來。
這不是要你放棄 SSH,而是替 SSH 補上它本來沒有的管理能力。


結語

一路追問 4 題下來,我留下的感受是:「安全地進行遠端操作」這句話,其實把三件不同的事包在一起了。

  1. 挖出一條加密的管道(金鑰交換)
  2. 確認管道兩端各是誰(主機金鑰與使用者認證)
  3. 在同一條管道裡同時傳送多個會話(channel)

RFC 會拆成 4252(認證)、4253(傳輸層)、4254(連線層),也是對應這三層。

而我以前把「SSH 可以做的事」分開記的那些功能——shell、SFTP、scp、埠轉發、agent forwarding——其實全都是建立在第 3 層之上的不同 channel。
之所以覺得難記,大概是因為我把同一套概念的延伸,當成了彼此獨立的功能在背。

參考

  1. RFC 4253 §4.2 的正式稱呼是 identification string。這和認證時伺服器顯示的 SSH_MSG_USERAUTH_BANNER(例如 /etc/issue.net 那類)不是同一回事;如果要精準表述,兩者要分開。這個 identification string 之後也會納入金鑰交換雜湊的材料,所以它不只是招呼語,也是竄改偵測的對象。
  2. OpenSSH 10.0 release notes:「ssh(1): the hybrid post-quantum algorithm mlkem768x25519-sha256 is now used by default for key agreement.」這是針對「現在先錄下加密內容,將來等量子電腦成熟後再解密」的攻擊所做的措施。
  3. RFC 9941 §3。客戶端送出的 Q_Csntrup761 的 1158 位元組公開金鑰輸出與 32 位元組 X25519 公開值的串接;伺服器回傳的 Q_Ssntrup761 的 1039 位元組密文輸出與 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.」
    每一條都是 channel,像 channel 0 這樣會有編號。
  12. RFC 9113 §5。HTTP/2 的 stream 會在單一連線上多工傳輸,而且彼此獨立。
  13. 這個 draft 在經過 13 版後於 2007 年失效,但 OpenSSH 實作的是對應 revision 3 的 02 版。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 release notes:「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= 一樣把執行內容固定住。

原文出處:https://qiita.com/hrfm1623/items/91115760e4bd66f7995a


精選技術文章翻譯,幫助開發者持續吸收新知。

共有 0 則留言


精選技術文章翻譯,幫助開發者持續吸收新知。
🏆 本月排行榜
🥇
站長阿川
📝8   💬4   ❤️2
229
🥈
我愛JS
📝1   💬2   ❤️1
51
評分標準:發文×10 + 留言×3 + 獲讚×5 + 點讚×1 + 瀏覽數÷10
本數據每小時更新一次
📢 贊助商廣告 · 我要刊登