CVE-2024-6387 OpenSSH RCE

基本信息

如果客户端未在 LoginGraceTime 秒(默认情况下为 120,旧 OpenSSH 版本中为 600)内进行身份验证,则 sshd 的 SIGALRM 处理程序将被异步调用,但此信号handler 调用各种非异步信号安全的函数(例如 syslog())。此竞争条件会影响 sshd 的默认配置。

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

环境搭建

SSH 9.7P1
glibc 2.37

技术分析

根据文章,我们知道这个漏洞在 752250c引入,主要是由于在sigdie函数中去除了#ifdef DO_LOG_SAFE_IN_SIGHAND

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

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

#define sigdie ssh_sigdie
#define ssh_sigdie(...) sshsigdie(__FILE__, __func__, __LINE__, __VA_ARGS__)

而原代码如下,显然当这个宏没有定义时不会调用

void
sigdie(const char *fmt,...)
{
#ifdef DO_LOG_SAFE_IN_SIGHAND
	va_list args;

	va_start(args, fmt);
	do_log(SYSLOG_LEVEL_FATAL, fmt, args);
	va_end(args);
#endif
	_exit(1);
}

查找这个宏,在满足下面的条件,DO_LOG_SAFE_IN_SIGHAND会被定义,对于本次漏洞而言,关键是SYSLOG_R_SAFE_IN_SIGHAND

#if defined(HAVE_OPENLOG_R) && defined(SYSLOG_DATA_INIT) && \
    defined(SYSLOG_R_SAFE_IN_SIGHAND)
# define DO_LOG_SAFE_IN_SIGHAND
#endif

全局查找可知在OpenBSD上编译sshd时,SYSLOG_R_SAFE_IN_SIGHAND会被定义,进而能够调用到漏洞关键部分do_log/sshlogv

*-*-openbsd*)
	use_pie=auto
	AC_DEFINE([HAVE_ATTRIBUTE__SENTINEL__], [1], [OpenBSD's gcc has sentinel])
	AC_DEFINE([HAVE_ATTRIBUTE__BOUNDED__], [1], [OpenBSD's gcc has bounded])
	AC_DEFINE([SSH_TUN_OPENBSD], [1], [Open tunnel devices the OpenBSD way])
	AC_DEFINE([SYSLOG_R_SAFE_IN_SIGHAND], [1],
	    [syslog_r function is safe to use in in a signal handler])
	TEST_MALLOC_OPTIONS="AFGJPRX"
	;;

查找资料可知,让AI解释一下,很明显syslog_r是syslog的异步安全版本,但这个只在OpenBSD可用,也就是说在使用glibc的 Linux上,syslog是异步不安全版本。

The [`syslog_r`](https://man.openbsd.org/syslog.3#syslog_r)() function is a reentrant version of the `syslog`() function. It takes a pointer to a syslog_data structure which is used to store information. This parameter must be initialized before `syslog_r`() is called. The `SYSLOG_DATA_INIT` constant is used for this purpose.

反向查找sigdie的引用,这两个函数分别注册为SIGCHLD和SIGALRM的处理函数,而grace_alarm_handler函数会在登录超时时自动调用

static void
sshpam_sigchld_handler(int sig)
{
	ssh_signal(SIGCHLD, SIG_DFL);
	if (cleanup_ctxt == NULL)
		return;	/* handler called after PAM cleanup, shouldn't happen */
	if (waitpid(cleanup_ctxt->pam_thread, &sshpam_thread_status, WNOHANG)
	    <= 0) {
		/* PAM thread has not exitted, privsep slave must have */
		kill(cleanup_ctxt->pam_thread, SIGTERM);
		while (waitpid(cleanup_ctxt->pam_thread,
		    &sshpam_thread_status, 0) == -1) {
			if (errno == EINTR)
				continue;
			return;
		}
	}
	if (WIFSIGNALED(sshpam_thread_status) &&
	    WTERMSIG(sshpam_thread_status) == SIGTERM)
		return;	/* terminated by pthread_cancel */
	if (!WIFEXITED(sshpam_thread_status))
		sigdie("PAM: authentication thread exited unexpectedly");
	if (WEXITSTATUS(sshpam_thread_status) != 0)
		sigdie("PAM: authentication thread exited uncleanly");
}
/*
 * Signal handler for the alarm after the login grace period has expired.
 */
static void
grace_alarm_handler(int sig)
{
	/*
	 * Try to kill any processes that we have spawned, E.g. authorized
	 * keys command helpers or privsep children.
	 */
	if (getpgid(0) == getpid()) {
		ssh_signal(SIGTERM, SIG_IGN);
		kill(0, SIGTERM);
	}

	/* Log error and exit. */
	sigdie("Timeout before authentication for %s port %d",
	    ssh_remote_ipaddr(the_active_state),
	    ssh_remote_port(the_active_state));
}

跟随qualys的文章,下载9.7P1代码,fine,让我看看怎么个事,此处为了方便只梳理9.7版本的ssh。

grace_alarm_handler调用sigdie,经过一系列调用,最终调用到syslog

static void
grace_alarm_handler(int sig)
{
	sigdie("Timeout before authentication for %s port %d",
}
void
packet_close(void)
{
...
	buffer_free(&input);
	buffer_free(&output);
	buffer_free(&outgoing_packet);
	buffer_free(&incoming_packet);
...
}
do_log(LogLevel level, int force, const char *suffix, const char *fmt,
    va_list args)
{
......
		syslog(pri, "%.500s", fmtbuf);

在glibc(基于2.37)中syslog 实现如下

void
__vsyslog_internal (int pri, const char *fmt, va_list ap,
		    unsigned int mode_flags)
{
.....
  __time64_t now = time64_now ();
  struct tm now_tm;
  struct tm *now_tmp = __localtime64_r (&now, &now_tm);

struct tm *
__localtime64_r (const __time64_t *t, struct tm *tp)
{
  return __tz_convert (*t, 1, tp);
}
struct tm *
__tz_convert (__time64_t timer, int use_localtime, struct tm *tp)
{
...
  tzset_internal (tp == &_tmbuf && use_localtime);
}
tzset_internal (int always)
{
...
  __tzfile_read (tz, 0, NULL);
}

void
__tzfile_read (const char *file, size_t extra, char **extrap)
{
...
  f = fopen (file, "rce");
}
FILE *
__fopen_internal (const char *filename, const char *mode, int is32)
{
  struct locked_FILE
  {
    struct _IO_FILE_plus fp;
#ifdef _IO_MTSAFE_IO
    _IO_lock_t lock;
#endif
    struct _IO_wide_data wd;
  } *new_f = (struct locked_FILE *) malloc (sizeof (struct locked_FILE));

  if (new_f == NULL)
    return NULL;
#ifdef _IO_MTSAFE_IO
  new_f->fp.file._lock = &new_f->lock;
#endif
  _IO_no_init (&new_f->fp.file, 0, 0, &new_f->wd, &_IO_wfile_jumps);
  _IO_JUMPS (&new_f->fp) = &_IO_file_jumps;
  _IO_new_file_init_internal (&new_f->fp);
  if (_IO_file_fopen ((FILE *) new_f, filename, mode, is32) != NULL)
    return __fopen_maybe_mmap (&new_f->fp.file);

  _IO_un_link (&new_f->fp);
  free (new_f);
  return NULL;
}

在一系列调用后,glibc会在调用malloc在堆内分配内存用于存储locked_FILE结构体,malloc在glibc中的代码如下,

_int_malloc (mstate av, size_t bytes)
{
  /*
     Convert request size to internal form by adding SIZE_SZ bytes
     overhead plus possibly more to obtain necessary alignment and/or
     to obtain a size of at least MINSIZE, the smallest allocatable
     size. Also, checked_request2size returns false for request sizes
     that are so large that they wrap around zero when padded and
     aligned.
   */

  nb = checked_request2size (bytes);

首先_int_malloc函数会通过checked_request2size函数检查传入的需要分配的内存大小,并且执行调整大小的逻辑

static inline size_t
checked_request2size (size_t req) __nonnull (1)
{
  /* When using tagged memory, we cannot share the end of the user
     block with the header for the next chunk, so ensure that we
     allocate blocks that are rounded up to the granule size.  Take
     care not to overflow from close to MAX_SIZE_T to a small
     number.  Ideally, this would be part of request2size(), but that
     must be a macro that produces a compile time constant if passed
     a constant literal.  */
  if (__glibc_unlikely (mtag_enabled))
    {
      /* Ensure this is not evaluated if !mtag_enabled, see gcc PR 99551.  */
      asm ("");

      req = (req + (__MTAG_GRANULE_SIZE - 1)) &
	    ~(size_t)(__MTAG_GRANULE_SIZE - 1);
    }

  return request2size (req);
}

接着在_int_malloc的后半部分,会把分配的内存切割为两部分nb和remainder_size,其中nb部分返回给调用者,remainder部分保留,并把它放到未排序chunk的末尾。

size = chunksize (victim);
              remainder_size = size - nb;
             ...
              else
                {
                  remainder = chunk_at_offset (victim, nb);

                  /* We cannot assume the unsorted list is empty and therefore
                     have to perform a complete insert here.  */
                  bck = unsorted_chunks (av);
                  fwd = bck->fd;
		  if (__glibc_unlikely (fwd->bk != bck))
		    malloc_printerr ("malloc(): corrupted unsorted chunks 2");
                  remainder->bk = bck;
                  remainder->fd = fwd;
                  bck->fd = remainder;
                  fwd->bk = remainder;

                  /* advertise as last remainder */
                  if (in_smallbin_range (nb))
                    av->last_remainder = remainder;
                  if (!in_smallbin_range (remainder_size))
                    {
                      remainder->fd_nextsize = NULL;
                      remainder->bk_nextsize = NULL;
                    }
                  set_head (victim, nb | PREV_INUSE |
                            (av != &main_arena ? NON_MAIN_ARENA : 0));
                  set_head (remainder, remainder_size | PREV_INUSE);
                  set_foot (remainder, remainder_size);

在研究员设想中,如果代码执行到bck->fd = remainder;,此时将remainder放到未排序chunk中,但还没有执行set_head函数。如果此时触发SIGALRM,执行流中断,重新跳转到grace_alarm_handler函数,这个放到未排序chunks中的chunk大小没有被初始化为remainder_size

如果可以修改remainder块的大小字段,那这个堆块将和后面的堆块重叠,也就是可以通过remainder堆块修改后续堆里面的内容,而先前fopen函数在堆内申请了内存用于存储FILE结构体i,FILE结构体定义如下

struct _IO_FILE
{
....
  signed char _vtable_offset;
  char _shortbuf[1];
  _IO_lock_t *_lock;
#ifdef _IO_USE_OLD_IO_FILE
};

其中的_vtable_offset用来实现类似于c++中的虚函数表作用,其指向了一个函数指针表。如果覆盖这个偏移量指向其他函数,则在后续处理过程中我们可以调用其他函数,在文章中调用的是_IO_wfile_underflow函数,这个函数会调用__fct函数指针。从而跳转到我们恶意的执行流。

后续就是堆布局了,由于我没有实际调试因此堆布局内容读者可以看原文,其步骤说明的很好。

修复

对比9.8p1和9.7p1,很明显可以找到修复的地方,在修复中,删除了sshsigdie函数,并在auth-pam.csshd.c中将sigdie的调用替换成了其他函数

小结

这个漏洞名为regreSSHion,实质上是CVE-2006-5051在新版本的SSH中又出现了,开发者不小心将CVE-2006-5051的补丁代码删除,导致了这个漏洞的出现。

就其可利用性而言,文章里面使用的是32位环境,且硬编码了glibc的映射地址,在更广泛的真实世界,大部分服务器是64位系统,且由于ASLR和PIE、NX等保护措施均使得这个漏洞难度利用很高。

攻击者需要知道具体的glibc版本,定制偏移,且因为要在准确的时间触发SIGALRM,这对网络延迟要求很高,在实际环境中网络抖动不可避免的会影响漏洞的利用。所以在真实环境中这个漏洞比较鸡肋,但由于ssh的广泛部署,不排除有僵尸网络分布式对同一目标尝试利用、爆破

参考链接

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

创建于2024-07-02

创建于:Wednesday, July 3,2024
最后修改于: Wednesday, July 3,2024