
This is a POC I wrote for CVE-2024-6387
Qualys Security Advisory
regreSSHion: RCE in OpenSSH's server, on glibc-based Linux systems (CVE-2024-6387)
摘要 SSH-2.0-OpenSSH_3.4p1 Debian 1:3.4p1-1.woody.3 (Debian 3.0r6, 2005 年版)
All it takes is a leap of faith
-- The Interrupters, "Leap of Faith"
初步说明:OpenSSH 是世界上最安全的软件之一;此漏洞是其近乎完美的实现中一次偶然失误。其纵深防御设计和代码是典范和灵感来源,我们感谢 OpenSSH 开发者的杰出工作。
我们在 OpenSSH 服务器(sshd)中发现了一个漏洞(信号处理程序竞争条件):如果客户端未能在 LoginGraceTime 秒内(默认为 120 秒,旧版 OpenSSH 中为 600 秒)完成身份验证,则 sshd 的 SIGALRM 处理程序会被异步调用,但该信号处理程序调用了多个非异步信号安全的函数(例如 syslog())。此竞争条件在默认配置下影响 sshd。
经过调查,我们意识到此漏洞实际上是 CVE-2006-5051(“OpenSSH 4.4 之前版本中的信号处理程序竞争条件允许远程攻击者导致拒绝服务(崩溃),并可能执行任意代码”)的回归,该漏洞由 Mark Dowd 于 2006 年报告。
此回归是由 2020 年 10 月(OpenSSH 8.5p1)的提交 752250c(“为 OpenSSH 修订的日志基础设施”)引入的,该提交意外地从 sigdie() 函数中移除了“#ifdef DO_LOG_SAFE_IN_SIGHAND”,而 sigdie() 直接被 sshd 的 SIGALRM 处理程序调用。换句话说:
此漏洞在基于 glibc 的 Linux 系统上可被远程利用,因为 syslog() 本身会调用异步信号不安全的函数(例如 malloc() 和 free()):这是一个以 root 身份执行的未认证远程代码执行,因为它影响 sshd 的特权代码,该代码未经沙箱化且拥有完全权限。我们尚未调查其他 libc 或操作系统;但 OpenBSD 显然不受影响,因为其 SIGALRM 处理程序调用 syslog_r(),这是 OpenBSD 于 2001 年发明的 syslog() 的异步信号安全版本。
为了远程利用此漏洞(据我们所知,CVE-2006-5051 此前从未被成功利用),我们从 Michal Zalewski 于 2001 年发表的一篇具有远见的论文《Delivering Signals for Fun and Profit》中汲取了灵感:
https://lcamtuf.coredump.cx/signals.txt
然而,我们立即面临三个主要问题:
从理论角度,我们必须找到一条有用的代码路径,如果该路径在适当时间被 SIGALRM 中断,会使 sshd 处于不一致状态,然后我们必须在 SIGALRM 处理程序内部利用此不一致状态。
从实践角度,我们必须找到一种方法在 sshd 中到达这条有用的代码路径,并最大化在正确时间中断它的机会。
从时机角度,我们必须找到一种方法进一步增加在正确时间中断这条有用代码路径的机会,并且是远程实现。
为了专注于这三个问题,而不必立即对抗所有现代操作系统保护(特别是 ASLR 和 NX),我们决定先利用旧的 i386 上的 OpenSSH 版本,然后基于此经验,再针对较新版本:
首先,“SSH-2.0-OpenSSH_3.4p1 Debian 1:3.4p1-1.woody.3”,来自“debian-30r6-dvd-i386-binary-1_NONUS.iso”:这是第一个默认启用权限分离,并已修补那个时代所有关键漏洞(特别是 CVE-2003-0693 和 CVE-2002-0640)的 Debian 版本。
为了远程利用此版本,我们使用 SIGALRM 中断对 free() 的调用(在 sshd 的公钥解析代码中),使堆处于不一致状态,然后在 SIGALRM 处理程序中对 free() 的另一次调用期间利用此不一致状态。
在我们的实验中,平均需要约 10,000 次尝试才能赢得此竞争条件;即,600 秒(LoginGraceTime)内接受 10 个连接(MaxStartups),平均约需 1 周才能获得远程 root shell。
其次,“SSH-2.0-OpenSSH_4.2p1 Debian-7ubuntu3”,来自“ubuntu-6.06.1-server-i386.iso”:这是最后一个仍受 CVE-2006-5051 影响的 Ubuntu 版本(“OpenSSH 4.4 之前版本中的信号处理程序竞争条件”)。
为了远程利用此版本,我们使用 SIGALRM 中断对 pam_start() 的调用,使 PAM 的某个结构处于不一致状态,然后在 SIGALRM 处理程序中对 pam_end() 的调用期间利用此不一致状态。
在我们的实验中,平均需要约 10,000 次尝试才能赢得此竞争条件;即,120 秒(LoginGraceTime)内接受 10 个连接(MaxStartups),平均约需 1-2 天才能获得远程 root shell。
最后,“SSH-2.0-OpenSSH_9.2p1 Debian-2+deb12u2”,来自“debian-12.5.0-i386-DVD-1.iso”:这是当前的 Debian 稳定版,并且受 CVE-2006-5051 回归的影响。
为了远程利用此版本,我们使用 SIGALRM 中断对 malloc() 的调用(在 sshd 的公钥解析代码中),使堆处于不一致状态,然后在 SIGALRM 处理程序中对 malloc() 的另一次调用期间(更准确地说,在 syslog() 内部)利用此不一致状态。
在我们的实验中,平均需要约 10,000 次尝试才能赢得此竞争条件,因此 120 秒(LoginGraceTime)内接受 100 个连接(MaxStartups)大约需要 3-4 小时。最终,平均约需 6-8 小时才能获得远程 root shell,因为我们只有一半时间能正确猜测 glibc 的地址(由于 ASLR)。
这项研究仍在进行中:
在我们开始 amd64 工作几天后,我们注意到 OpenSSH 的公共 Bugzilla 中有以下关于 sshd 的 SIGALRM 处理程序中死锁的错误报告:
https://bugzilla.mindrot.org/show_bug.cgi?id=3690
因此,我们决定立即联系 OpenSSH 开发者(告知他们此死锁是由一个可利用的漏洞引起的),暂停了我们的 amd64 工作,并开始编写此公告。
But that's not like me, I'm breaking free
-- The Interrupters, "Haven't Seen the Last of Me"
此 OpenSSH 版本的 SIGALRM 处理程序调用 packet_close(),后者调用 buffer_free(),然后调用 xfree() 进而调用 free(),而 free() 不是异步信号安全的:
因此,我们开始阅读此 Debian 的 glibc(2.2.5)的 malloc 代码,以查看对 free() 的第一次调用是否可以被 SIGALRM 中断,并在 SIGALRM 处理程序中对 free() 的第二次调用期间(上述第 341-344 行)被利用。由于该 glibc 的 malloc 未针对 Solar Designer 于 2000 年首创的 unlink() 技术进行加固,我们很快在 chunk_free()(由 free() 内部调用)中发现了一条有趣的代码路径:
为了利用此代码路径,我们安排 sshd 的堆具有以下布局(chunk_X、chunk_Y 和 chunk_Z 是 malloc() 分配的内存块,p、s、f、b 分别是它们的 prev_size、size、fd 和 bk 字段):
-----|---+---------------|---+---------------|---+---------------|----- ... |p|s|f|b| chunk_X |p|s|f|b| chunk_Y |p|s|f|b| chunk_Z | ... -----|---+---------------|---+---------------|---+---------------|----- |<------------->| user data
首先,如果对 free(chunk_Y) 的调用在第 3246 行 之后 但在第 3251 行 之前 被 SIGALRM 中断,则 chunk_Y 已被标记为空闲(因为第 3246 行清除了 chunk_Z 的 PREV_INUSE 位),但尚未链接到其双向链表中(第 3251 行):换句话说,chunk_Y 的 fd 和 bk 指针仍然包含用户数据(攻击者控制的数据)。
其次,如果(在 SIGALRM 处理程序内部)packet_close() 调用 free(chunk_X),则进入第 3230-3244 行的代码块(因为 chunk_Y 被标记为空闲),并在第 3241 行对 chunk_Y 进行 unlink():所谓 aa4bmo 原语(几乎任意 4 字节镜像覆盖),因为 chunk_Y 的 fd 和 bk 指针仍然由攻击者控制。有关 unlink() 技术和 aa4bmo 原语的更多信息:
https://www.openwall.com/articles/JPEG-COM-Marker-Vulnerability#exploit http://phrack.org/issues/61/6.html#article
最后,使用此 aa4bmo 原语,我们覆盖 glibc 的 __free_hook 函数指针(此旧版 Debian 既没有 ASLR 也没有 NX),指向堆中 shellcode 的地址,从而在 packet_close() 中下一次调用 free() 时实现远程代码执行。
Now they're taking over and they got complete control
-- The Interrupters, "Liberty"
为了对 sshd 发起此攻击,我们在 sshd 解析 DSA 公钥的代码中中断对 free() 的调用(即下面的第 144 行是我们的 free(chunk_Y)),并在 packet_close() 中的某个 free() 调用中利用它(即上述第 341-344 行中的某一处是我们的 free(chunk_X)):
然而,一开始我们从未能够赢得此竞争条件(即在正确时间中断第 144 行的 free() 调用)。最终,我们意识到可以极大地提高赢得竞争的机会:DSA 公钥解析代码允许我们调用 free() 四次(下面第 704-707 行),而且 sshd 允许我们尝试六次用户身份验证(AUTH_FAIL_MAX);如果这 24 个 free() 调用中的任何一个在正确时间被中断,那么我们稍后可以在 SIGALRM 处理程序内部实现远程代码执行。
有了这一改进,我们最终在大约 1 个月后赢得了竞争条件:我们很高兴(并跳起了 root shell 之舞),但同时也觉得仍有改进空间。
Don't worry, just wait and see
-- The Interrupters, "Haven't Seen the Last of Me"
因此,我们实施了以下三方面时机策略:
我们不会等到最后一刻才将(相当大的)DSA 公钥数据包发送给 sshd:相反,我们在 LoginGraceTime 之前很久就发送整个数据包减去一个字节(最后一个字节),并在最后一刻发送最后一个字节,以最大限度地减少网络延迟的影响。(并禁用 Nagle 算法。)
我们跟踪中位数往返时间(通过定期发送能产生 sshd 响应的数据包),并跟踪预期连接被 sshd 关闭的时刻(基本上是我们收到 sshd 横幅的第一个字节的时间加上 LoginGraceTime)与实际连接被 sshd 关闭的时刻之间的差异,并相应调整我们的时机(即发送 DSA 数据包最后一个字节的时刻)。
这些时间差使我们能够跟踪时钟偏差和网络延迟,这些偏差和延迟随时间呈现可预测的模式:我们尝试了线性回归和样条回归,但最终,没有什么比简单地重用最近一次测量更有效。可能深度学习会带来更好的结果;这留给感兴趣的读者作为练习。
更重要的是,我们通过从 sshd 获得的无意反馈来缓慢调整时机,从而进一步提高赢得竞争条件的机会:
这种反馈使我们能够瞄准所谓的“大”竞争窗口:击中它并不能保证我们赢得竞争条件,但在这个大窗口内包含 24 个“小”竞争窗口(在 24 个 free() 调用内部),如果击中其中任何一个,就能保证我们赢得竞争条件。
有了这些改进,平均需要约 10,000 次尝试才能赢得此竞争条件;即,600 秒(LoginGraceTime)内接受 10 个连接(MaxStartups),平均约需 1 周才能获得远程 root shell。
I sleep when the sun starts to rise
-- The Interrupters, "Alien"
此 OpenSSH 版本的 SIGALRM 处理程序不再调用 packet_close();此外,此 Ubuntu 的 glibc(2.3.6)在进入 malloc 系列函数时总是获取强制锁(即使是像 sshd 这样的单线程程序也是如此),这阻止了我们中断对 malloc 函数的调用并在稍后对这些函数的另一次调用中进行利用(它们总是会死锁)。我们必须寻找其他解决方案。
CVE-2006-5051 提到了 GSSAPI 中的 double-free,但 GSSAPI(或 Kerberos)默认未启用,因此这听起来不太有吸引力。另一方面,PAM 默认启用,并且 pam_end() 被 sshd 的 SIGALRM 处理程序调用(当然,它不是异步信号安全的)。因此,我们寻找了一个 PAM 函数,如果它在正确时间被 SIGALRM 中断,会使 PAM 的内部结构处于不一致状态,并可在 SIGALRM 处理程序中的 pam_end() 期间利用。我们找到了 pam_set_data():
33 int pam_set_data( 34 pam_handle_t *pamh, .. 37 void (*cleanup)(pam_handle_t pamh, void data, int error_status)) 38 { 39 struct pam_data data_entry; .. 57 } else if ((data_entry = malloc(sizeof(data_entry)))) { .. 65 data_entry->next = pamh->data; 66 pamh->data = data_entry; .. 74 data_entry->cleanup = cleanup; ------------------------------------------------------------------------如果此函数在第66行之后但在第74行之前被SIGALRM中断,则data_entry已经链接到PAM的结构(pamh)中,但其cleanup字段(一个函数指针)尚未初始化(因为第57行的malloc()未初始化其内存)。如果我们能够控制cleanup(通过之前堆分配的残留数据),那么当pam_end()(在SIGALRM处理函数内部)调用_pam_free_data()(在第118行)时,我们可以执行任意代码:
这会是一个非常简单的利用;不幸的是,我们完全忽略了pam_set_data()只能从PAM模块调用:如果我们用SIGALRM中断它,那么pamh->caller_is仍然是_PAM_CALLED_FROM_MODULE,在这种情况下pam_end()会立即返回,根本不会调用_pam_free_data()。回到绘图板。
我们不会放弃,这不是我们的作风
-- The Interrupters, "Title Holder"
我们注意到,在第601行,sshd将其全局的sshpam_handle指针直接传递给pam_start()(每个连接调用一次):
因此我们决定研究pam_start()本身:如果被SIGALRM中断,它可能使sshpam_handle指向的结构处于不一致状态,然后可以在SIGALRM处理函数调用"pam_end(sshpam_handle, sshpam_err)"时被利用。
在第32行,pam_start()立即将sshd的sshpam_handle设置为一个calloc()分配的内存块;这是安全的,因为calloc()将此内存初始化为零。另一方面,如果_pam_add_handler()(由pam_start()多次调用)在第874行之后但在第886行之前被SIGALRM中断,则一个malloc()分配的结构被链接到pamh中,但其next字段尚未初始化。如果我们能够控制next(通过之前堆分配的残留数据),那么我们可以在pam_end()调用(在SIGALRM处理函数内部)中,向free()传递任意指针,如下文第1020行(以及第1017行)所示:
由于此Ubuntu的glibc的malloc已经针对旧的unlink()技术进行了加固,我们决定将我们的任意free()转化为Malloc Maleficarum的House of Mind(fastbin版本):我们释放自己的NON_MAIN_ARENA块,将我们的虚假arena指向sshd的.got.plt(此Ubuntu的sshd启用了ASLR但没有PIE),并用堆中shellcode的地址(此Ubuntu的堆默认仍是可执行的)覆盖_exit()的条目。关于Malloc Maleficarum的更多信息:
https://seclists.org/bugtraq/2005/Oct/118
我什么都是通过艰难的方式学会的
-- The Interrupters, "The Hard Way"
要针对sshd发起这次攻击,我们最初面临三个问题:
House of Mind要求我们将指向虚假arena的指针存储在堆中的地址0x08100000处;但我们能否在如此高的地址存储受攻击者控制的数据?由于sshd在用户身份验证的最开始调用pam_start(),我们除了用户名本身之外无法控制任何东西;幸运的是,一个长度约为128KB(小于DEFAULT_MMAP_THRESHOLD)的用户名允许我们将自己的数据存储在地址0x08100000处。
我们的虚假NON_MAIN_ARENA块的大小字段不能太大(以通过free()的安全检查);即,它必须包含空字节。但我们的长用户名是一个以空字符结尾的字符串,不能包含空字节;幸运的是,我们记得_pam_free_handlers_aux()将其释放的结构清零(上面的第1019行):因此,我们首先用这样的memset(0)来“修补”我们虚假块的大小字段,然后再free()它。
在释放我们的虚假NON_MAIN_ARENA块之前,我们必须承受多次free()调用(上面的第1017行和第1020行)。我们通过将这些free()指向虚假的IS_MMAPPED块,将其转化为无操作:free()调用munmap_chunk(),而munmap_chunk()调用munmap(),由于这些虚假的IS_MMAPPED块未对齐,munmap()会失败;实际上是一个无操作,因为此Ubuntu的glibc中不强制执行assert()失败。
最后,我们的长用户名还允许我们控制20个不同结构可能未初始化的next字段(通过长用户名的临时副本的残留数据),因为pam_start()多次调用_pam_add_handler();也就是说,我们的大竞争窗口包含20个小竞争窗口。
他们以前用过同样的把戏
-- The Interrupters, "Divide Us"
对于针对Ubuntu 6.06.1的这次攻击,我们简单地重用了针对Debian 3.0r6时使用的时机策略:平均需要约10,000次尝试才能赢得竞争条件,并且每120秒(LoginGraceTime)接受10个连接(MaxStartups),平均需要约1-2天才能获得远程root shell。
注意:由于此Ubuntu的glibc在进入malloc家族函数时总是获取强制锁,一个不幸的攻击者可能在获得root shell之前死锁所有10个MaxStartups连接;我们没有尝试解决这个问题,因为我们的最终目标无论如何都是利用现代OpenSSH版本。
现在你准备好了,直面恶魔吧
-- The Interrupters, "Be Gone"
此OpenSSH版本的SIGALRM处理函数既不调用packet_close()也不调用pam_end();实际上它只调用一个有趣的函数:syslog():
那么,我们的两个关键问题是:此Debian的glibc(2.36)中的syslog()是否调用异步信号不安全的函数,如malloc()和free()?如果是,此glibc在进入malloc家族函数时是否仍然获取强制锁?
注意:因为我们无法控制这些malloc()的任何方面(顺序、大小和内容都没有),我们将第166行的“rce”视为一个急需的好兆头。
对我们来说幸运的是,第二个问题的答案是否定的;自2017年10月以来,glibc的malloc函数在单线程(如sshd)时不再获取任何锁:
https://sourceware.org/git?p=glibc.git;a=commit;h=a15d53e2de4c7d83bda251469d92a3c7b49a90db https://sourceware.org/git?p=glibc.git;a=commit;h=3f6bb8a32e5f5efd78ac08c41e623651cc242a89 https://sourceware.org/git?p=glibc.git;a=commit;h=905a7725e9157ea522d8ab97b4c8b96aeb23df54
此外,此Debian版本还存在以下优秀博客文章中描述的ASLR弱点(分别由Justin Miller和Mathias Krause撰写):
https://zolutal.github.io/aslrnt/ https://grsecurity.net/toolchain_necromancy_past_mistakes_haunting_aslr
具体来说,在i386上的sshd中,每个内存映射都是正常随机化的(sshd的PIE、堆、大多数库、栈),但glibc本身总是映射在地址0xb7200000或0xb7400000;换句话说,我们可以一半的情况下正确猜出glibc的地址(这是击败ASLR的一个小代价)。在我们的利用中,我们假设glibc映射在地址0xb7400000,因为它比0xb7200000稍微常见一些。
我们的下一个问题是:glibc的malloc函数中的哪些代码路径,如果在正确的时间被SIGALRM中断,会使堆处于不一致状态,从而可以在SIGALRM处理函数的一次malloc()调用期间被利用?
我们找到了几个有趣(且令人惊讶!)的代码路径,但我们选择的一个只涉及相对大小,而不是绝对地址(例如与unlink_chunk()中的各种代码路径不同);这种差异可能对未来amd64的利用至关重要。这个在malloc()内部的代码路径将一个大的空闲块(victim)分成两个较小的块;第一个块返回给malloc()的调用者(第4345行),第二个块(remainder)被链接到空闲块的未排序列表中(第4324-4327行):
如果此代码路径在第4327行之后但在第4339行之前被SIGALRM中断,那么这次分割的remainder块已经链接到空闲块的未排序列表中(第4324-4327行),但其大小字段(mchunk_size)尚未初始化(第4339行)。
如果我们能够控制其大小字段(通过之前堆分配的残留数据),那么我们可以使这个remainder块变得更大,并与其他堆块重叠,从而当这个被放大、重叠的remainder块最终被malloc()分配并写入时(在SIGALRM处理函数内部),可以破坏堆内存。
那么,我们最后一个问题是:鉴于我们无法控制SIGALRM处理函数内部malloc()调用的任何方面,我们可以在堆中覆盖什么,以便在sshd调用_exit()(在sshsigdie()中)之前实现任意代码执行?
由于__tzfile_read()(在SIGALRM处理函数内部)在堆中malloc()分配一个FILE结构(上面的第166行),并且由于FILE结构长期以来一直被滥用于任意代码执行,我们决定将堆破坏的目标对准这个FILE结构。然而,这说起来容易做起来难:我们的堆破坏非常有限,而且FILE结构多年来已经被显著加固(例如通过IO_validate_vtable()和PTR_DEMANGLE())。
最终,我们设计了以下技术(这似乎是i386 glibc特有的——amd64 glibc似乎根本不使用_vtable_offset):
通过我们有限的堆破坏,我们覆盖__tzfile_read()的FILE结构的_vtable_offset字段(单个有符号字符);
glibc的libio函数将因此在这个FILE结构的vtable指针(指向函数指针数组的指针)的偏移量(我们覆盖的_vtable_offset)处查找,而不是默认的零偏移量;
我们(攻击者)可以轻松控制这个虚假的vtable指针(通过之前堆分配的残留数据),因为FILE结构中此偏移量附近的部分并未被fopen()显式初始化;
为了通过glibc的安全检查,我们的虚假vtable指针必须指向__libc_IO_vtables段中的某个位置:我们决定将其指向宽字符流的vtable,即_IO_wfile_jumps(即0xb761b740,因为我们假设glibc映射在地址0xb7400000);
结果,__fread_unlocked()(上面的第186行)调用_IO_wfile_underflow()(而不是_IO_file_underflow()),它调用一个函数指针(__fct),该函数指针基本上来自一个结构体,其指针(_codecvt)是FILE结构的另一个字段;
我们(攻击者)可以轻松控制这个_codecvt指针(通过之前堆分配的残留数据,因为FILE结构的这个字段并未被fopen()显式初始化),这也使我们能够控制__fct函数指针。
总结:通过覆盖fopen() malloc()分配的FILE结构的一个字节(_vtable_offset),我们可以在__fread_unlocked()期间调用我们自己的__fct函数指针并执行任意代码。
我想要它完美无瑕
-- The Interrupters, "In the Mirror"
要针对sshd的特权子进程发起这次攻击,让我们首先想象以下堆布局("XXX"是“屏障”块,允许我们在堆中制造空洞;例如,小的内存泄漏块):
---|----------------------------------------------|---|------------|---
| XXX | 大空洞 | XXX | 小空洞 | XXX |
|---|---|---|---|---|
| ~8KB | 320B |
---|-----------------------|----------------------|---|------------|---
| XXX | 大分配块 | 空闲余数块 | XXX | 小空洞 | XXX |
|---|---|---|---|---|---|
| ~4KB | ~4KB | 320B |
但如果这个malloc()在第4327行之后但在第4339行之前被SIGALRM中断,那么这次分割的余数块已经链接到空闲块的未排序列表中,但其大小字段处于我们的控制之下(通过之前堆分配的残留数据),并且这个人为放大的余数块会与后面的小空洞重叠:---|-----------------------|----------------------|---|------------|--- XXX| 大分配块 | 实际剩余块 |XXX| 小空洞 |XXX ---|-----------------------|----------------------|---|------------|--- | ~4KB |<------------------------------------->| 人为扩大的剩余块
当 SIGALRM 处理程序调用 syslog() 进而调用 __tzfile_read() 时, fopen() 为它的 FILE 结构 malloc() 了这个小空洞, __fread_unlocked() 为 4KB 读取缓冲区 malloc() 了一个空间, 从而将扩大的剩余块一分为二(4KB 读取缓冲区和一个小的剩余块):
---|-----------------------|----------------------|---|------------|--- XXX| 大分配块 | |XXX| FILE |XXX ---|-----------------------|----------------------|---|--|---------|--- | ~4KB |<--------------------------->|<------->| 4KB 读取缓冲区 剩余块
因此,我们用这个小的剩余块的内部头部覆盖了 FILE 结构的部分内容: 更准确地说,我们将 FILE 的 _vtable_offset 覆盖为该头部 bk 字段的第三个字节, 该字段是指向空闲块未排序列表的指针,0xb761d7f8 (即我们将 _vtable_offset 覆盖为 0x61);
然后,如“理论”小节所述,__fread_unlocked() 调用 _IO_wfile_underflow() (而不是 _IO_file_underflow()),该函数通过我们自己的 _codecvt 指针 调用我们自己的 __fct 函数指针,从而执行我们的任意代码。
注意:我们尚未解释如何从受控的 _codecvt 指针可靠地到达受控的 __fct 函数指针; 我们将在后面解释,但首先必须解决一个更紧迫的问题。
确实,我们从之前对较旧 OpenSSH 版本的工作中得知, 如果我们的“大竞态窗口”中只包含一个“小竞态窗口”, 我们永远无法赢得这个信号处理程序竞态条件。 因此,我们基于以下堆布局实现了以下策略:
---|------------|---|------------|---|------------|---|------------|---
| XXX | 大空洞 1 | XXX | 小空洞 1 | XXX | 大空洞 2 | XXX | 小空洞 2 | ... |
|---|---|---|---|---|---|---|---|---|
| ~8KB | 320B |
我们发送给 sshd 的最后一个数据包(在 SIGALRM 发送前不久) 迫使 sshd 执行以下 malloc() 调用序列: malloc(~4KB), malloc(304), malloc(~4KB), malloc(304) 等等。
1/ 我们的第一个 malloc(~4KB) 将大空洞 1 一分为二:
如果这个第一次分裂在正确的时间被 SIGALRM 中断, 那么 SIGALRM 处理程序中的 fopen() 会将小空洞 1 malloc() 为其 FILE 结构, 从而我们如上所述实现任意代码执行;
如果没有中断,那么我们自己用第一个 malloc(304) 将小空洞 1 malloc() 出来,然后:
2/ 我们的第二个 malloc(~4KB) 将大空洞 2 一分为二:
如果这个第二次分裂在正确的时间被 SIGALRM 中断, 那么 SIGALRM 处理程序中的 fopen() 会将小空洞 2 malloc() 为其 FILE 结构, 从而我们如上所述实现任意代码执行;
如果没有中断,那么我们自己用第二个 malloc(304) 将小空洞 2 malloc() 出来,等等。
我们能够在 sshd 的堆中创建 27 对这样的大、小空洞 (28 对将超过 PACKET_MAX_SIZE,256KB): 我们的“大竞态窗口”现在包含 27 个“小竞态窗口”! 实现这种复杂的堆布局非常痛苦且耗时,但两个亮点如下:
为了可靠地实现这种堆布局,我们向 sshd 发送五个不同的公钥数据包 (数据包 a/ 到 d/ 可以在 SIGALRM 之前很久发送; 数据包 e/ 的大部分也可以在 SIGALRM 之前很久发送, 但其最后一个字节必须在最后一刻发送):
a/ 我们 malloc() 和 free() 各种 tcache 块,以确保我们不控制的堆分配 最终落在这些 tcache 块中,并且不干扰我们精心设计的堆布局。
b/ 我们 malloc() 和 free() 不同大小的块,以创建我们的 27 对大、小空洞 (以及相应的“屏障”块)。
c/ 我们 malloc() 和 free() ~4KB 块和 320B 块,以:
d/ 我们 malloc() 和 free() 一个非常大的字符串(接近 256KB),以确保 我们的大、小空洞从未排序的空闲块列表中移除,并放入它们各自的 malloc bins。
e/ 我们迫使 sshd 执行我们的最终 malloc() 调用序列 (malloc(~4KB), malloc(304), malloc(~4KB), malloc(304) 等), 以打开我们的 27 个小竞态窗口。
细心的读者可能已经注意到,我们仍然没有解决(字面上和比喻上)_codecvt 的问题。 实际上,_codecvt 是一个指向结构体的指针(_IO_codecvt), 该结构体包含一个指向结构体的指针(__gconv_step), 该结构体包含允许我们执行任意代码的 __fct 函数指针。 为了通过 _codecvt 可靠地控制 __fct,我们只需将 _codecvt 指向 glibc 的一个 malloc bin, 该 bin 恰好包含一个指向我们堆中某个空闲块的指针, 该空闲块包含我们自己的指向任意 glibc 代码的 __fct 函数指针 (所有这些 glibc 地址都是已知的,因为我们假设 glibc 映射在地址 0xb7400000)。
我们没有时间了
-- The Interrupters, "As We Live"
当我们实现第三个漏洞时,很明显我们不能简单地重用 我们之前针对两个较旧 OpenSSH 版本使用的时序策略: 我们从未赢得这个新的竞态条件。 最终,我们理解了为什么:
sshd 解析我们的第五个也是最后一个公钥(上面的数据包 e/) 需要很长时间(约 10ms);换句话说,我们的“大竞态窗口”太大了 (我们的 27 个小竞态窗口就像大海捞针)。
最近引入的 user_specific_delay()(OpenSSH 7.8p1) 将 sshd 对我们最后一个公钥数据包的响应延迟最多约 9ms, 因此破坏了我们基于反馈的时序策略。
因此,我们开发了一种完全不同的时序策略:
有时,我们发送最后一个公钥数据包时带有一个小错误, 该错误会在调用解析我们公钥的 sshkey_from_blob() 之前 (下面第 138-142 行)产生一个错误响应;
有时,我们发送最后一个公钥数据包时带有另一个小错误, 该错误会在调用解析我们公钥的 sshkey_from_blob() 之后 (下面第 151-155 行)产生一个错误响应;
这两个响应时间之差就是 sshd 解析我们最后一个公钥所需的时间, 这使我们能够精确地计时发送最后一个数据包 (以确保 sshd 有足够的时间在非特权子进程中解析我们的公钥, 将其发送到特权子进程,并开始在那里解析,然后才交付 SIGALRM)。
采用这种策略变更后,平均需要约 10,000 次尝试才能赢得竞态条件; 也就是说,在每 120 秒(LoginGraceTime)接受 100 个连接(MaxStartups)的情况下, 平均需要约 3-4 小时才能赢得竞态条件, 而获得远程 root shell 则需要约 6-8 小时(由于 ASLR)。
你明天的计划是什么?
-- The Interrupters, "Take Back the Power"
我们决定针对 Rocky Linux 9(Red Hat Enterprise Linux 9 的衍生版) (来自 "Rocky-9.4-x86_64-minimal.iso"),原因有二:
其 OpenSSH 版本(8.7p1)容易受到此信号处理程序竞态条件的影响, 并且其 glibc 总是映射在 2MB 的倍数地址上 (由于先前“理论”小节中讨论的 ASLR 弱点), 这使得部分指针覆盖更加强大;
此 glibc 版本(2.34)中的 syslog() 函数 (异步信号不安全,但被 sshd 的 SIGALRM 处理程序调用) 内部调用 __open_memstream(),该函数在堆中 malloc() 一个 FILE 结构, 并且还调用 calloc()、realloc() 和 free() (这给了我们一些急需的自由)。
凭借堆破坏作为原语、堆中 malloc() 的两个 FILE 结构以及 glibc 地址中的 21 个固定位, 我们相信这个信号处理程序竞态条件在 amd64 上是可利用的 (可能不在 6-8 小时内,但希望在一周内)。只有时间才能证明。
附带说明:我们发现 Ubuntu 24.04 不会重新随机化其 sshd 子进程的 ASLR (仅在启动时随机化一次);我们追溯到以下补丁, 该补丁关闭了 sshd 的 rexec_flag。 这通常是一个坏主意,但在这个信号处理程序竞态条件的特定情况下, 它阻止了 sshd 被利用: SIGALRM 处理程序中的 syslog() 不会调用任何 malloc 函数, 因为它从来不是对 syslog() 的第一次调用。
https://git.launchpad.net/ubuntu/+source/openssh/tree/debian/patches/systemd-socket-activation.patch
风暴已过
-- The Interrupters, "Good Things"
2024 年 6 月 6 日,此信号处理程序竞态条件通过提交 81c1099 (“为 sshd(8) 添加一个用于惩罚特定有问题的客户端行为的设施”)修复, 该提交将异步信号不安全代码从 sshd 的 SIGALRM 处理程序移动到 sshd 的监听进程, 在那里可以同步处理:
https://github.com/openssh/openssh-portable/commit/81c1099d22b81ebfd20a334ce986c4f753b0db29
由于此修复是一个大型提交(81c1099)的一部分, 并且基于一个更大的纵深防御提交(03e3de4,“开始将 sshd 拆分为单独的二进制文件”), 因此可能难以向后移植。在这种情况下, 可以通过从 sshsigdie() 函数中移除或注释掉异步信号不安全代码来修复信号处理程序竞态条件本身; 例如:
sshsigdie(const char *file, const char *func, int line, int showfunc, LogLevel level, const char *suffix, const char *fmt, ...) { #if 0 va_list args;
va_start(args, fmt);
sshlogv(file, func, line, showfunc, SYSLOG_LEVEL_FATAL,
suffix, fmt, args);
va_end(args);
最后,如果无法更新或重新编译 sshd,可以通过简单地在配置文件中将 LoginGraceTime 设置为 0 来修复此信号处理程序竞态条件。这会使 sshd 容易受到拒绝服务攻击 (耗尽所有 MaxStartups 连接),但使其免受本公告中介绍的远程代码执行攻击。
我们感谢 OpenSSH 开发人员在此版本中的出色工作和密切合作。 我们还感谢 distros@openwall。 最后,我们将此公告献给 Sophia d'Antoine。
2024-05-19:我们联系了 OpenSSH 的开发人员。随后进行了多次迭代的补丁和补丁审查。
2024-06-20:我们联系了 distros@openwall。
2024-07-01:协调发布日期。
| ~8KB |
| 320B |