前言
前置关键词:SSH 客户端/服务器,Linux/Unix 系统的用户账户,TCP/IP,Socket。
本文撰于 2022 年 9 月,若相关内容有更新请按照引用链接内的内容为准。
本文介绍了 SSH 协议在验证用户身份过程中的实现细节,想帮助读者更加深入的了解 SSH 客户端与服务器的工作过程。也因此,本文可能不适用于指导 SSH 服务器或客户端的配置。
本文在撰写中参考了下列内容。
SSH 架构 RFC 4251 The Secure Shell (SSH) Protocol Architecture
SSH 传输层协议 RFC 4253 The Secure Shell (SSH) Transport Layer Protocol
SSH 身份验证协议 RFC 4252 The Secure Shell (SSH) Authentication Protocol
SSH 连接协议 RFC 4254 The Secure Shell (SSH) Connection Protocol
SSH 交互式身份验证 RFC 4256 Generic Message Exchange Authentication for the Secure Shell Protocol (SSH)
SSH 协议的结构
SSH 协议的基本框架
建立一个 SSH 连接,将会经过下面几个过程。
(明文通信)建立 TCP 连接
(明文通信)协商 SSH 协议版本 (本质上是相互发送包含版本号的字符串)
服务器将自己的 SSH 协议版本发送到客户端,格式为:SSH-protoversion(版本号)-softwareversion(自定义) SP(空格一个,可选) comments(注释,可选) CR(回车) LF(换行)
客户端将自己的 SSH 协议版本发送到服务器,格式为:SSH-protoversion(版本号)-softwareversion(自定义) SP(空格一个,可选) comments(注释,可选) CR(回车符) LF(换行符)
(明文通信)协商密钥
服务器发送公钥
客户端和服务器相互发送支持的相关加密算法列表、MAC 算法列表
使用 D-H 算法 生成此后通讯中使用的对称加密密钥(会话密钥)
(密文通信)身份认证
(密文通信)正式进入应用 (如打开 Shell、SFTP、端口转发)
从上面的流程中可以看出,身份验证过程实际上发生在 SSH 连接建立之后,此后客户端与服务器之间传输的密码、密钥、信息都已经受到 SSH 协议生成的「会话密钥」加密。
关于「安全」
SSH 的全称是「Secure Shell」,安全 Shell。其中的「安全」不是指使用 SSH 就能进入一个绝对的安全世界,而是包含了传输安全和身份安全。
假设,我正在打电话给身在公司的朋友,询问一些机要信息。此时攻击者就可以剪断我屋外的电话线,分别接上两个电话听筒对听筒放在一起。我和我的朋友都不会注意到问题,而攻击者可以从中得知我们之间传输的信息。这是网络中存在的中间人攻击。
SSH 避免了这个问题,客户端与服务器之间的数据传输经过了上面的过程而加密(SSH 传输层协议 RFC 4253)。这个加密是无关于用户的账号密码的,在事前就完成。无论是网络中的交换机还是跳板机都无法直接获取加密前的明文。SSH 保护了传输时的安全。
SSH 也同样提供了验证用户身份的方式,客户端可以将用户的密码发送至服务器,以此让服务器确认用户的身份,只允许被授权的访问连接到服务器。
SSH 的安全不是绝对的。比如攻击者拿起剪断的电话线(中间人攻击),他依旧可以通过客户端与服务器之间的通讯内容推测出这是 SSH 连接。SSH 协议并不能保证连接不被侦测到特征。
身份验证方式
密码(Password)
使用密码登录是常用且较为便捷的认证方式。在配置文件 /etc/ssh/sshd_config 中添加 PasswordAuthentication yes 以开启密码登录。
使用 ssh 命令时提示输入密码
通常也认为使用密码登录是一种较为脆弱的认证方式。这不是说使用密码会造成传输时的不安全,而是对密码本身保存的担忧。一般的用户密码都是十位或数十位字符,可能被记录与纸上或记事本中。即使不是如此,有意义的密码也容易遭到社会工程攻击而泄露。
在 /etc/ssh/sshd_config 文件中有时会设置 PermitRootLogin prohibit-password ,要求系统管理用户 root 不得使用密码登录。
根据 RFC 中的描述,用户认证过程是在连接握手之后的。此时客户端与服务器之间已经建立起了加密的连接。双方都会使用握手时交换好的密钥加密所有传输内容。后文中的 SSH 数据样式都是被加密传输的。
登录时,登录请求由客户端发起。一个名称为 SSH_MSG_USERAUTH_REQUEST 的消息从客户端发出,包含了登录的用户名与密码。
C: byte SSH_MSG_USERAUTH_REQUEST
C: string user name
C: string service name
C: string "password"
C: boolean FALSE
C: string plaintext password in ISO-10646 UTF-8 encoding [RFC3629]
S: byte SSH_MSG_USERAUTH_SUCCESS
若登录认证失败,服务器将回复 SSH_MSG_USERAUTH_FAILURE。
基本上,这样的消息往复就完成了平时常见的登录过程。
除此之外,在 RFC 标准中还有一个用于服务器响应密码登录请求的消息。
通常,服务器会成功或失败地响应此消息。但是,如果密码已过期,服务器应通过 SSH_MSG_USERAUTH_PASSWD_CHANGEREQ 响应来指示这一点。在任何情况下,服务器都不得允许使用过期密码进行身份验证。
后文介绍的 keyboard-interactive 登录方式也可以做出密码过期提示。
公钥私钥(Publickey)
在 RFC 标准中,公钥验证方式是唯一必须实现的验证方式(The only REQUIRED authentication)。所有实现都必须(MUST, RFC2119)支持这种方法。在 /etc/ssh/sshd_config 文件中使用 PubkeyAuthentication yes 开启公钥验证方式。
此验证方式需要用户先准备一个非对称加密的密钥对,将公钥保存至 SSH 服务器的 ~/.ssh/authorized_key 文件中。客户端登录时,在本地用私钥加密某个信息,并将结果发送给服务器,服务器将通过公钥验证收到的密文是否来自指定的用户。
私钥通常以加密的形式存储在客户主机上,用户必须在生成签名之前提供一个口令(passphrase)。 即使不是这样,签名操作也涉及一些昂贵的计算。 为了避免不必要的处理和用户互动,提供以下信息来查询使用 "公钥 "方法的认证是否可以接受。
C: byte SSH_MSG_USERAUTH_REQUEST
C: string user name in ISO-10646 UTF-8 encoding [RFC3629]
C: string service name in US-ASCII
C: string "publickey"
C: boolean FALSE
C: string public key algorithm name
C: string public key blob
任何公钥算法都可以被提供给认证使用,如果请求中的算法不被服务器支持,它必须直接拒绝该请求。
服务器必须以 SSH_MSG_USERAUTH_FAILURE 或以下方式回应该消息。
S: byte SSH_MSG_USERAUTH_PK_OK
S: string public key algorithm name from the request
S: string public key blob from the request
之后,客户端会使用私钥加密一个消息(消息的构成方式参见 RFC 4252),将结果发送给服务器。下面消息中的 signature 即为加密运算后的内容。
C: byte SSH_MSG_USERAUTH_REQUEST
C: string user name
C: string service name
C: string "publickey"
C: boolean TRUE
C: string public key algorithm name
C: string public key to be used for authentication
C: string signature
使用公钥方式登录的优点是,密钥对基本不可能被写在纸上(密钥是很长的随机文本,社会工程攻击中只能通过更困难的间接方式窃取这么长的内容);在网络上传输、保存的通常是密钥对的公钥文件,而非私钥文件。
更加显而易见的好处是,私钥文件是保存在客户端的计算机上的。使用 SSH 命令时就无需再反复输入密码。因此网络上很多教程使用此方式作为免密码登录的方式。与此同时,因为需要预先将公钥放在服务器上(通常是通过网络上传),其也确实不便于配置。
交互式(keyboard-interactive)
在 RFC 文档中,这个验证方式被视作是前述方案的一种扩展。允许 SSH 客户端和服务器在获取身份验证信息时进行一些交互。如要启用此方式需在 /etc/ssh/sshd_config 文件中添加 ChallengeResponseAuthentication yes 。通常情况下,这个验证模式会与系统内的 PAM 模块一同启用。以此来支持谷歌验证器(多因素验证),或其他内部身份校验模块。
NextSSH 在连接需要交互式验证的服务器时的提示
使用交互式验证可以允许用户输入更多的信息,获得更多的提示内容。
从 RFC 中来看,此验证模式也是从客户端发起身份验证请求开始。
C: byte SSH_MSG_USERAUTH_REQUEST
C: string user name (ISO-10646 UTF-8, as defined in [RFC-3629])
C: string service name (US-ASCII)
C: string "keyboard-interactive" (US-ASCII)
C: string language tag (as defined in [RFC-3066])
C: string submethods (ISO-10646 UTF-8)
当服务器得知客户端准备使用 keyboard-interactive 为验证方式后,服务器会向客户端发出用户信息请求。在这个来自服务器的请求中,服务器将提供提示文本(instruction, prompt)并且为每一个字段(或者称为询问)标记一个序号(num-prompts)。
S: byte SSH_MSG_USERAUTH_INFO_REQUEST
S: string name (ISO-10646 UTF-8)
S: string instruction (ISO-10646 UTF-8)
S: string language tag (as defined in [RFC-3066])
S: int num-prompts
S: string prompt[1] (ISO-10646 UTF-8)
S: boolean echo[1]
S: ...
S: string prompt[num-prompts] (ISO-10646 UTF-8)
S: boolean echo[num-prompts]
收到来自 SSH 服务器的请求后,客户端即可开始向用户展示界面获取信息。在使用 ssh 命令时,通常会在终端内等待用户输入,具有 GUI 的软件将会展示提示界面。
使用 ssh 命令时提示输入质询信息
当用户完成输入后,客户端即可发送用户信息响应。
C: byte SSH_MSG_USERAUTH_INFO_RESPONSE
C: int num-responses
C: string response[1] (ISO-10646 UTF-8)
C: ...
C: string response[num-responses] (ISO-10646 UTF-8)
这样的过程(服务器请求-用户输入-客户端响应)可能会重复多次。例如用户输入的密码错误,或者服务器依据情况请求了更多的信息。
下面是来自 RFC 文档中的,客户端和服务器之间的两个交换例子。 第一个例子是用需处理的 Token 进行验证的例子。这是一种其他认证方法无法实现的方式。
C: byte SSH_MSG_USERAUTH_REQUEST
C: string "user23"
C: string "ssh-userauth"
C: string "keyboard-interactive"
C: string ""
C: string ""
S: byte SSH_MSG_USERAUTH_INFO_REQUEST
S: string "CRYPTOCard Authentication"
S: string "The challenge is ’14315716’"
S: string "en-US"
S: int 1
S: string "Response: "
S: boolean TRUE
[Client prompts user for password]
C: byte SSH_MSG_USERAUTH_INFO_RESPONSE
C: int 1
C: string "6d757575"
S: byte SSH_MSG_USERAUTH_SUCCESS
第二个例子是一个标准的密码认证。但在此例子中,用户的密码已经过期。
C: byte SSH_MSG_USERAUTH_REQUEST
C: string "user23"
C: string "ssh-userauth"
C: string "keyboard-interactive"
C: string "en-US"
C: string ""
S: byte SSH_MSG_USERAUTH_INFO_REQUEST
S: string "Password Authentication"
S: string ""
S: string "en-US"
S: int 1
S: string "Password: "
S: boolean FALSE
[Client prompts user for password]
C: byte SSH_MSG_USERAUTH_INFO_RESPONSE
C: int 1
C: string "password"
S: byte SSH_MSG_USERAUTH_INFO_REQUEST
S: string "Password Expired"
S: string "Your password has expired."
S: string "en-US"
S: int 2
S: string "Enter new password: "
S: boolean FALSE
S: string "Enter it again: "
S: boolean FALSE
[Client prompts user for new password]
C: byte SSH_MSG_USERAUTH_INFO_RESPONSE
C: int 2
C: string "newpass"
C: string "newpass"
S: byte SSH_MSG_USERAUTH_INFO_REQUEST
S: string "Password changed"
S: string "Password successfully changed for user23."
S: string "en-US"
S: int 0
[Client displays message to user]
C: byte SSH_MSG_USERAUTH_INFO_RESPONSE
C: int 0
S: byte SSH_MSG_USERAUTH_SUCCESS