Bootstrap

[漏洞分析] CVE-2024-6387 OpenSSH核弹核的并不是很弹


先说结论,在当前已经披露的漏洞利用方法而言,利用非常困难,也不能说完全利用不了吧,只能在特定场景成功,作者给了三个案例。但总的来说,核的并不是很弹,省流直接看下面盖棺定论

漏洞简介

漏洞编号: CVE-2024-6387

漏洞产品: OpenSSH - sshd

影响范围: 8.5p1 <= OpenSSH < 9.8p1

利用条件: 见最后"盖棺定论"章节

利用效果: 远程代码执行(RCE)(理论环境,单纯以博客中展示的漏洞利用方法而言,现网中几乎不可能完成利用

漏洞作者的博客如下:

https://www.qualys.com/2024/07/01/cve-2024-6387/regresshion.txt

由于作者没公开利用代码,并且利用实在太麻烦,本文纯纯基于该漏洞作者博客中的描述进行理论分析,不涉及实际调试。所有结论均为我YY的,不作为处置建议

漏洞原理

补丁分析

漏洞是在commit 752250c中引入的,关键点在于,该修改重命名了sigdie函数,并且去掉了#ifdef DO_LOG_SAFE_IN_SIGHAND

在这里插入图片描述

在这里插入图片描述

根据该宏定义的名字不难看出,该宏定义的意思是,在信号安全需求场景,不会执行中间的log部分代码,而是直接exit。现在去掉了该宏,则无条件执行这段log的代码。

漏洞原理

我们先来看看sshsigdie会在哪里使用:

log.h:

#define sigdie(...)		sshsigdie(__FILE__, __func__, __LINE__, 0, SYSLOG_LEVEL_ERROR, NULL, __VA_ARGS__)

这里将sshsigdie 定义为sigdie。

sshd.c:

/*
 * Signal handler for the alarm after the login grace period has expired.
 */
/*ARGSUSED*/
static void
grace_alarm_handler(int sig)
{
	if (use_privsep && pmonitor != NULL && pmonitor->m_pid > 0)
		kill(pmonitor->m_pid, SIGALRM);

	/*
	 * Try to kill any processes that we have spawned, E.g. authorized
	 * keys command helpers.
	 */
	if (getpgid(0) == getpid()) {
		ssh_signal(SIGTERM, SIG_IGN);
		kill(0, SIGTERM);
	}

	/* XXX pre-format ipaddr/port so we don't need to access active_state */
	/* Log error and exit. */
	sigdie("Timeout before authentication for %s port %d",
	    ssh_remote_ipaddr(the_active_state),
	    ssh_remote_port(the_active_state));
}

在grace_alarm_handler 函数中调用了sigdie打印log,看名字就知道grace_alarm_handler 是一个时钟信号的处理函数。在main函数中注册为SIGALRM信号的处理函数:

sshd.c:

    /*
	 * We don't want to listen forever unless the other side
	 * successfully authenticates itself.  So we set up an alarm which is
	 * cleared after successful authentication.  A limit of zero
	 * indicates no limit. Note that we don't set the alarm in debugging
	 * mode; it is just annoying to have the server exit just when you
	 * are about to discover the bug.
	 */
	ssh_signal(SIGALRM, grace_alarm_handler);
	if (!debug_flag)
		alarm(options.login_grace_time);

在options.login_grace_time 秒之后如果没有登录完成,则抛出超时信号SIGALRM。该参数默认为120,可以通过-g参数设置:

在这里插入图片描述
在这里插入图片描述

接下来看一下sshsigdie 函数中的逻辑,为什么这个函数在信号不安全场景会出问题:

void
sshsigdie(const char *file, const char *func, int line, int showfunc,
    LogLevel level, const char *suffix, const char *fmt, ...)
{
	va_list args;

	va_start(args, fmt);
	sshlogv(file, func, line, showfunc, SYSLOG_LEVEL_FATAL,
	    suffix, fmt, args);
	va_end(args);
	_exit(1);
}

调用了sshlogv:

void
sshlogv(const char *file, const char *func, int line, int showfunc,
    LogLevel level, const char *suffix, const char *fmt, va_list args)
{
	char tag[128], fmt2[MSGBUFSIZ + 128];
	int forced = 0;
	const char *cp;
	size_t i;

	snprintf(tag, sizeof(tag), "%.48s:%.48s():%d",
	    (cp = strrchr(file, '/')) == NULL ? file : cp + 1, func, line);
	for (i = 0; i < nlog_verbose; i++) {
		if (match_pattern_list(tag, log_verbose[i], 0) == 1) {
			forced = 1;
			break;
		}
	}

	if (log_handler == NULL && forced)
		snprintf(fmt2, sizeof(fmt2), "%s: %s", tag, fmt);
	else if (showfunc)
		snprintf(fmt2, sizeof(fmt2), "%s: %s", func, fmt);
	else
		strlcpy(fmt2, fmt, sizeof(fmt2));

	do_log(file, func, line, level, forced, suffix, fmt2, args);
}

最后调用了do_log:

static void
do_log(const char *file, const char *func, int line, LogLevel level,
    int force, const char *suffix, const char *fmt, va_list args)
{
    ··· ···
	if (log_handler != NULL) {
		··· ···
	} else {
#if defined(HAVE_OPENLOG_R) && defined(SYSLOG_DATA_INIT)
		openlog_r(argv0 ? argv0 : __progname, LOG_PID, log_facility, &sdata);
		syslog_r(pri, &sdata, "%.500s", fmtbuf);
		closelog_r(&sdata);
#else
		openlog(argv0 ? argv0 : __progname, LOG_PID, log_facility);
		syslog(pri, "%.500s", fmtbuf);
		closelog();
#endif
	}
	errno = saved_errno;
}

可以看到这个函数中调用了syslog(),而syslog是信号不安全的函数,这其中也会使用大量的如malloc、free等信号不安全的libc函数。所以漏洞的根因是:在信号处理函数中使用信号不安全函数。

漏洞利用

指的一提的是,该漏洞并不是第一次出现,实际上这个漏洞就是CVE-2006-5051 漏洞的再现,上面提到的被删除的关键代码#ifdef DO_LOG_SAFE_IN_SIGHAND正式为了修复CVE-2006-5051 增加的,但是在上述commit中被错误删除,导致CVE-2006-5051 又重现。但不得不说CVE-2006-5051 漏洞从来没有被利用成功过。

除此之外,不可忽略的博客中的关于他们所尝试成功的漏洞利用的前提是:

  • 漏洞利用只针对虚拟机,而不是裸机服务器,控制网络的数据包抖动在0~10ms 之间;

漏洞利用1: SSH-2.0-OpenSSH_3.4p1 Debian 1:3.4p1-1.woody.3 (Debian 3.0r6, from 2005) [无ASLR无NX]

漏洞利用原理

该漏洞利用的环境没有ASLR与NX。

这个漏洞利用基本就是上古版本的ubuntu,来自2005年,其实这个漏洞利用尝试主要是尝试利用CVE-2006-5051。而且很容易就成功了,我们可以通过这个漏洞来理解一下他是如何利用的这个漏洞,并且理解一下该漏洞想要利用成功需要满足的前提条件。

首先,20年前的代码和现在有很大不同,我自然也懒得去找20年前的代码了反正思路是相同的,这个漏洞的原理还是相当简单的,直接看博客即可,根据博客中的描述,20年前的漏洞中大概使用的信号不安全函数不是syslog,而是packet_close,并且packet_close中调用了free:

------------------------------------------------------------------------
 302 grace_alarm_handler(int sig)
 303 {
 ...
 307         packet_close();
------------------------------------------------------------------------
 329 packet_close(void)
 330 {
 ...
 341         buffer_free(&input);
 342         buffer_free(&output);
 343         buffer_free(&outgoing_packet);
 344         buffer_free(&incoming_packet);
------------------------------------------------------------------------
 35 buffer_free(Buffer *buffer)
 36 {
 37         memset(buffer->buf, 0, buffer->alloc);
 38         xfree(buffer->buf);
 39 }
------------------------------------------------------------------------
 51 xfree(void *ptr)
 52 {
 53         if (ptr == NULL)
 54                 fatal("xfree: NULL pointer given as argument");
 55         free(ptr);
 56 }
------------------------------------------------------------------------

free函数是一个经典的信号不安全函数。在上古版本的glibc中,gree流程有如下关键代码:

------------------------------------------------------------------------
1028 struct malloc_chunk   这一部分是堆块结构,跟现在一样
1029 {
1030   INTERNAL_SIZE_T prev_size; /* Size of previous chunk (if free). */
1031   INTERNAL_SIZE_T size;      /* Size in bytes, including overhead. */
1032   struct malloc_chunk* fd;   /* double links -- used only if free. */
1033   struct malloc_chunk* bk;
1034 };
------------------------------------------------------------------------
2516 #define unlink(P, BK, FD)                                           \
2517 {                                                                   \
2518   BK = P->bk;                                                       \
2519   FD = P->fd;                                                       \
2520   FD->bk = BK;                                                      \
2521   BK->fd = FD;                                                      \
2522 }                                                                   \
------------------------------------------------------------------------
3160 chunk_free(arena *ar_ptr, mchunkptr p)
....
3164 {
3165   INTERNAL_SIZE_T hd = p->size; /* its head field */
....
3177   sz = hd & ~PREV_INUSE;
3178   next = chunk_at_offset(p, sz);
3179   nextsz = chunksize(next);
....
3230   if (!(inuse_bit_at_offset(next, nextsz)))  这个分支的意思是,下一个堆块如果是释放状态,则将其unlink出来,然后跟当前要释放的堆块合并,在重新加入链表
3231   {
....
3241       unlink(next, bck, fwd);
....
3244   }
3245   else
3246     set_head(next, nextsz);                  /* clear inuse bit */
....
3251     frontlink(ar_ptr, p, sz, idx, bck, fwd);
------------------------------------------------------------------------

我们需要做的大概是构造如下堆布局,关于glibc的堆结构,经常打CTF的同学应该都很熟悉,不在这里解释了,直接看漏洞利用:

-----|---+---------------|---+---------------|---+---------------|-----
 ... |p|s|f|b|  chunk_X  |p|s|f|b|  chunk_Y  |p|s|f|b|  chunk_Z  | ...
-----|---+---------------|---+---------------|---+---------------|-----
                             |<------------->|
                                 user data

p,s,f,b分别是prev_size,size,fd 和 bk 字段。构造三个连续的堆块,chunk_X, chunk_Y, chunk_Z,其中chunk_Y是我们内容可控的堆块。当一个堆块在被使用状态的时候,fd和bk字段是没用的,这连个字段的位置是用户数据,也就是我们可控的数据。那么我们需要让他在free chunk_Y的过程中触发SIGARLM信号中断,并且中断的位置必须在上述代码的3246到3251之间,这样已经执行完set_head(next, nextsz);也就是已经修改chunk_Z的prev_inused位,已经将chunk_Y标记为释放,但还没将其正式添加到free_list中,这时触发中断。

进入到中断逻辑中,中断逻辑中还有free函数,这时需要再中断逻辑中free chunk_X堆块,这样在3230行,检测下一个堆块是否释放的时候,它会发现chunk_Y是释放的状态, 但他并不值得chunk_Y并不在free_list中,而是继续执行unlink函数,而参与unlink函数的两个指针fd和bk此时还是我们可控的用户数据。直接经典的unlink任意地址写,由于在该漏洞利用中没有ASLR与NX,则直接修改free_hook 到堆上,并在堆上部署shellcode完成利用。

漏洞利用关键点

有一个关键点被我一笔带过了,那就是,我们需要在正好free chunk_Y的时候的free的3246到3251之间触发超时中断。我不知道如何评价这个动作有多难,我只是说达成原理大家自行判断:

超时中断是在我们发起ssh链接的时候开始计时,默认状态120秒后触发。如果我们想要在正好free 的时候触发,那么我们得在发起ssh之后精确的等待120秒多一点触发free的操作。

但事实也并不是这么苛刻,漏洞作者采用的方法是通过DSA 公钥解析逻辑来触发free,因为该逻辑中有四次free我们可控内容的堆块的机会,并且sshd允许尝试6次认证,总共24次free机会。只要任意一次完成了,就是成功。

但即便如此代码的执行是非常快的,单条指令的执行时间是微妙级别,即便我们有24次机会,但触发从120秒之前设置的信号,还是很难,就好像赌博默示录中伊藤开司挑战柏青哥机器时需要连续进入123层3个小孔一样。

漏洞作者给出的一些优化方案是:

  • 再一次测试中如果我们收到对我们的 DSA 公钥数据包的响应(SSH2_MSG_USERAUTH_FAILURE),则说明我们发送得太早了
  • 如果我们甚至无法发送我们的 DSA 数据包的最后一个字节,那么我们发的太晚了(sshd 已经收到了 SIGALRM 并关闭了我们的连接);
  • 如果我们可以发送我们的 DSA 数据包的最后一个字节,并且在 sshd 关闭我们的连接之前没有收到任何响应,则我们的时间安排是相当准确的。

这三条基本可以让你对自己等待时间是否过长或过短有个判断,但即便如此能打到的效果大概就是,我能将等待时间缩短到一个比较小的范围内,但众所周知,这个时间每次都是不一样的,毕竟你要发送网络报文过去,该漏洞利用的环境是虚拟机,每次网络报文的延迟还算稳定,但如果换成远程网络,我都不敢想。

在虚拟机环境中,作者说平均10000次可以成功,没600秒处理10个连接,需要一周才能成功一次。

漏洞利用2: SSH-2.0-OpenSSH_4.2p1 Debian-7ubuntu3 (Ubuntu 6.06.1, from 2006) [无ASLR无NX]

同样是上古版本,但和上面的利用不同的是,不再使用packet_close函数,而是使用的pam系列的函数,原理大同小异,找到一个对全局资源的关键访问函数,并且在其还没完成完整的修改之前中断,然后在中断处理中再次访问该全局资源。

除了漏洞利用的目标函数有区别外,在时间优化方面没有进一步优化,还是需要上述漏洞利用的苛刻条件。除此之外,还是需要非NX环境,还是需要shellcode。

漏洞利用3: SSH-2.0-OpenSSH_9.2p1 Debian-2+deb12u2 (Debian 12.5.0, from 2024) [有ASLR无NX 32位]

该漏洞利用环境开启了ASLR,但该环境的ASLR 有问题,随的并不是很机,参考:

https://zolutal.github.io/aslrnt/

https://grsecurity.net/toolchain_necromancy_past_mistakes_haunting_aslr

然后呢漏洞利用原理大差不差,关于时间,在该版本中时间更加难以确定,但作者依旧采用了一个很好的区间法能够尽量确认正确等待时间的范围:

------------------------------------------------------------------------
 88 userauth_pubkey(struct ssh *ssh, const char *method)
 89 {
...
138         if (pktype == KEY_UNSPEC) {
139                 /* this is perfectly legal */
140                 verbose_f("unsupported public key algorithm: %s", pkalg);
141                 goto done;
142         }
143         if ((r = sshkey_from_blob(pkblob, blen, &key)) != 0) {
144                 error_fr(r, "parse key");
145                 goto done;
146         }
...
151         if (key->type != pktype) {
152                 error_f("type mismatch for decoded key "
153                     "(received %d, expected %d)", key->type, pktype);
154                 goto done;
155         }
------------------------------------------------------------------------

通过发送错误的数据包频繁触发138-142的错误和151-155的错误可以确认正确的数据包处理时间大概是多久。根据作者描述,120秒接受100个连接的情况下(注意远大于前两次的频率)大概3-4小时能成功竞争时间窗,6-8小时能成功爆破地址,根据他提供的数据,可以看出ASLR的问题还是很大的,只有两种可能这随机了个寂寞,况且最后漏洞利用还是shellcode,还是得在没有NX的环境才可以漏洞利用成功

盖棺定论

整体来说,我觉得漏洞的实际威胁程度不大,首先该利用在2024年的ubuntu环境之所以能成功,有如下几个先决条件:

  1. ASLR开了,但约等于没开,那个ASLR漏洞让ASLR随机几乎完全失效,只有两种地址可能。
  2. 不能开NX,还是依赖shellcode
  3. 在虚拟机中,网络的波动在可控范围内
  4. 120秒发起100个连接
  5. 32位环境

这上面的条件还只是硬性条件,满足了之后才能开始时间窗的竞争尝试,可见有多难,在虚拟机场景下3-4小时才能竞争成功一次时间窗也就是获得一次猜地址的机会,但凡ASLR正常也是利用不成的,除非欧皇一发入魂。

更别说64位环境了。

关于这个竞争时间窗,多说一嘴,想在网络环境中发起请求之后120秒出发超时中断的时候,还正好在我们目标的被中断的代码之间的难度,我理解大概就是一列火车正在全速行驶,你站在铁轨旁边向火车扔石头,石头正好从车厢之间穿过的概率吧。

poc分析

尽早来发现github上有poc了,链接如下:

https://github.com/zgzhang/cve-2024-6387-poc

其实大概原理都知道,主要是验证一下上面分析的结论是否正确:

  1. ASLR确实只有两种可能:

    在这里插入图片描述

  2. 最后用shellcode完成利用,所以还需要关闭NX

    在这里插入图片描述

这么看来当前确实很难利用,期待有人发现更强的理由方式。

;