在今年(2024)七月初的時候,各大資安媒體與資安社群皆談論到的 sshd 嚴重漏洞非 CVE-2024-6387 a.k.a regreSSHion 莫屬了。

相關報導:「OpenSSH含有可遠端攻陷伺服器的回歸漏洞」- iThome

如果略有接觸技術面,大概會知道漏洞成因是 sshd 上有 race condition 的漏洞導致 RCE,除了漏洞發現者寫的 Security Advisory 外,相當少人提及漏洞利用鏈是如何串起的。在自問「就一個 race condition 是要怎麼 RCE?」後,決定看完 security advisory 後將內容整理於此文中,希望可以幫助到對此漏洞背後利用方式同樣感到好奇的人們。

簡介 CVE-2024-6387 regreSSHion #

OpenSSH sshd 的 Race Condition 漏洞,主要成因為 sshd 在 SIGALRM Handler 中使用 async-signal-unsafe function,導致攻擊者可以利用 Signal Handler 創造出 glibc heap 狀態的不一致,最終達成高權限的 RCE。有趣的是,這個漏洞類似於 CVE-2006-5051,也是 Signal Handler Race Condition,這也是為什麼研究員決定將這個漏洞取名為 Regression 的衍伸字,差別在於古早時的這個漏洞只能 DoS 然後 regreSSHion 可以做到 RCE。

漏洞成因 #

752250c 這個 Commit 中,log.c 中的 sigdie() 裡面的 #ifdef DO_LOG_SAFE_IN_SIGHAND 整個 code block 在 Logging System Revise 的過程中被移除了。這導致 sigdie() 中會呼叫 do_log()(其中會呼叫 syslog())進行 Logging,然而 do_log() 執行時會使用到 async-signal-unsafe 的 function(例如 malloc(), free()),導致 Race Condition 至 RCE 可行。詳細的說,當 Signal Handler(在這裡的 sigdie())呼叫了 async-signal-unsafe function 並執行,此時再觸發其他 Signal 會讓程式狀態上的問題。

修改前的 sigdie():

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);
}

修改後的 sigdie()(在 Header File 有 #define sigdie ssh_sigdie):

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); <<< Root cause
    va_end(args);
    _exit(1);
}

漏洞利用方式 #

利用方式本文將著重於 SSH-2.0-OpenSSH_9.2p1 Debian-2+deb12u2,根據不同版本利用方式與 heap shaping 都有所不同,主要是因為 SIGALRM 在不同版本的 sshd 的行為不一樣,有些會關閉 connection 有些則只 log 資訊,這會影響到 Race Condition 時 async-unsafe-function 執行時的時機點。

Race against syslog() #

我們先來找 syslog() 從何出現。首先我們知道當 SSH 連線建立後,如果在 Client 不執行驗證的話,經過 LoginGracePeriod 後就會將連線中斷,那這個 LoginGracePeriod 則是通過 Alarm 設定的。當 SIGALRM 觸發時,grace_alarm_handler 會被呼叫。

grace_alarm_handler(int sig)
{
        sigdie("Timeout before authentication for %s port %d",
        ssh_remote_ipaddr(the_active_state),
        ssh_remote_port(the_active_state));
        ...
}

sigdie() 的定義則是一個展開為另一個 Function call 的 Macro,而這個 Macro 會呼叫到先前在「漏洞成因」提及的 sshsigdie()

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

一路往下追就可以找到 syslog()。

sshsigdie(const char *file, const char *func, int line, int showfunc,
    LogLevel level, const char *suffix, const char *fmt, ...)
{
        ...
        sshlogv(file, func, line, showfunc, SYSLOG_LEVEL_FATAL,
            suffix, fmt, args);
        ...
}
sshlogv(const char *file, const char *func, int line, int showfunc,
    LogLevel level, const char *suffix, const char *fmt, va_list args)
{
        ...
        do_log(level, forced, suffix, fmt2, args);
        ...
}
do_log(LogLevel level, int force, const char *suffix, const char *fmt,
    va_list args)
{
        ...
        syslog(pri, "%.500s", fmtbuf);
        ...
}

現在我們已經找到了 syslog() 在 SIGALRM 中被呼叫到的地方,那要怎麼通過 syslog() 這個 async-signal-unsafe 函數去做到 RCE 呢?這時需要提到 syslog() 中的行為,當 SIGALRM 第一次呼叫 syslog() 時,syslog() 會做出以下行為:

  • __localtime64_r()
    • malloc(304) allocate a FILE structure
  • malloc(4096) allocate an internal read buffer
__localtime64_r (const __time64_t *t, struct tm *tp)
{
		...
    return __tz_convert (*t, 1, tp);
__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);
__tzfile_read (const char *file, size_t extra, char **extrap)
{

    FILE *f;

    f = fopen (file, "rce"); <<<< FILE*

    if (__builtin_expect (__fread_unlocked ((void *) &tzhead, sizeof (tzhead), 1, f) != 1, 0) <<<< Read Buffer

從 code 片段可以看出,呼叫 syslog() 會進一步使用到 malloc。此外還需要提及 glibc 2.36 的一個特點,當程式本身為 Single-Threaded 時,malloc / free 並不會有 lock 的機制,也就是說 malloc / free 在 Single-Threaded 的 binary 中變成可以 reentrance 的函數了

接下來觀察以下 malloc 的程式碼片段,這段程式碼實際上在將一塊較大的 chunk 切成兩塊,一塊是使用者請求的 chunk 將回傳給使用者,則剩下的 chunk 將會被 link 到 unsorted-bin 內部。

#define set_head(p, s)       ((p)->mchunk_size = (s))

3765 _int_malloc (mstate av, size_t bytes)
3766 {
....
3798   nb = checked_request2size (bytes);
....
4295               size = chunksize (victim);
....
4300               remainder_size = size - nb;
....
4316                   remainder = chunk_at_offset (victim, nb);
....
4320                   bck = unsorted_chunks (av);
4321                   fwd = bck->fd;
....
4324                   remainder->bk = bck;
4325                   remainder->fd = fwd;
4326                   bck->fd = remainder;
4327                   fwd->bk = remainder;
....
4337                   set_head (victim, nb | PREV_INUSE |
4338                             (av != &main_arena ? NON_MAIN_ARENA : 0));
4339                   set_head (remainder, remainder_size | PREV_INUSE);
....
4343               void *p = chunk2mem (victim);
....
4345               return p;

如果我們在 L4327 之後,L4339 之前觸發 SIGALRM Interrupt,使得 remainder chunk 已進入 unsorted-bin 中但 chunk header 還沒被設定完成,導致 heap 出現異常! 我們可以利用這點,讓原先位於 chunk header 上的殘餘值成為分割出來的 chunk->mchunk_size,最終讓這塊 chunk 變成異常的大成為 overlap chunk。

Abuse FILE structure to RCE #

接下來要讓 SIGALRM 中的 localtime64_r(),取得被我們控制的 FILE Structure 使得呼叫 __fread_unlocked 時會跳到我們指定的 function pointer 上。

在這個版本的 glibc 中(i386),glibc libio function 會根據 _vtable_offset 這個欄位去尋找 FILE Structure 上的 vtable pointer。我們通過前述的 heap corruption 去複寫這個欄位,使得 vtable offset 不在原先的位置上,而是我們指定的某個 offset。

_IO_FILE:

struct _IO_FILE {
  ...
  int _fileno;
  int _flags2;
  _IO_off_t _old_offset;
  unsigned short _cur_column;
  signed char _vtable_offset; 	<<<<<<<< this field
  char _shortbuf[1];
  _IO_lock_t *_lock;
};

_IO_FILE_plus

struct _IO_FILE_plus
{
  _IO_FILE file;
  const struct _IO_jump_t *vtable;
};

glibc 在這張 vtable 上面做了許多檢查,vtable 上的 pointer 需要位於 __libc_IO_vtables 這個 section 上。所以只能從這個 section 中挑選目標。

我們將目標放在 wide-char stream 的 _IO_wfile_jumps 上。將 vtable 變成 _IO_wfile_jumps 上時,呼叫 __fread__unlocked 將不再呼叫 _IO_file_underflow 而是 _IO_wfile_underflow,最終執行流程將會呼叫 FILE structure 上 _codecvt structure 中的 function pointer(__fct)。

Heap Shaping #

我們可以通過指定 Offset 去其他位址,甚至是指到 FILE Structure 外面使得 libio 使用到 heap 上的殘留值。為了完成攻擊需要精準的控制 heap 上的內容,通過 Heap Shaping 來讓 heap 長成我們想要的樣子。

為了要讓 heap 出現狀態故障,並使得未來 syslog() 取得被我們控制的 FILE structure,我們希望 heap 可以長成這樣。

image-20240820232116440

當 SIGALRM 呼叫,我們 malloc 一塊 4KB 的 chunk,使得 8KB 的 chunk 被分成兩塊 4KB 的 chunk:

image-20240820232050288

此時我們使用先前提及的通過 SIGALRM 中斷 malloc() 中 set_head 的過程,使 remainder chunk 變得異常的大,足以 overlap 到後方的 chunk(上下兩行對應到的是同個記憶體空間,解讀方式不同):

image-20240820232200079

接著 __tzfile_read() 時,fopen 會 malloc 出 FILE structure:

image-20240820232418967

接著 __fread_unlocked 也會 malloc 出一塊 4KB 的 read buffer。此時 4KB 的 read buffer allocation 將會破壞 heap 完整性,通過控制好 remainder chunk 的位置,使 remainder chunk 的 header->bk 剛好蓋掉 FILE structure 上的 vtable_offset 欄位(1 Byte):

image-20240820232242241

最終我們通過控制 vtable_offset 來使 __fread__unlocked() 會呼叫到 __IO_wfile_underflow 而不是 __IO_file_underflow,進而去使用 FILE structure 上的 _codecvt 結構中的 function pointer。

Weak ASLR Beats i386 Machines #

在近期的 Linux Kernel 中,library address base ASLR 變得相當的弱,在 32-bit 下基本上沒有 ASLR 可言、而 64-bit 下原先的 randomize bit 變少,這使得 32-bit 的機器可以完全不需要 glibc address leak。

造成 Weak ASLR 的原因是因為 Huge Page,在 x86-64 下有兩種 Huge Page:其中一種是 2MB Huge Page,Page 有對齊(alignment)的需求,而 2MB Huge Page 需要像 4KB Page 一樣做 12-bit align,同時又需要被 21 bit align,而這之中的 9-bit 差距就是 ASLR degrade 的主因。

如果對 Weak ASLR 有興趣的夥伴們,可以參考「延伸閱讀 - ASLRn’t」。

Putting All together #

最後來將所有東西兜起來,為了要使 heap 堆疊成上述的模樣,我們需要一些 heap primitive 來堆疊 heap 成理想的樣子。另外因為是通過 race condition 進行攻擊,所以 exploit 過程中想辦法創造出多個能成功的 race window 提高成功率。

Heap 示意圖:

image-20240820232303789

這裏作者提出使用 sshd 的 public-key parsing 來作為 heap primitive 來完成任意大小的 heap malloc/free 操作,分別是 cert_parsecert_free

cert_parse(struct sshbuf *b, struct sshkey *key, struct sshbuf *certbuf)
{
      ...
      while (sshbuf_len(principals) > 0) {

              if ((ret = sshbuf_get_cstring(principals, &principal,

              key->cert->principals[key->cert->nprincipals++] = principal;
      }
      ...
}

cert_free(struct sshkey_cert *cert)
{
    ...
        for (i = 0; i < cert->nprincipals; i++)
                free(cert->principals[i]);
    ...
}

並通過 5 個不同的 public key packet 來完成 heap layout。每一個 packet 都會對 heap layout 產生不同的影響,這邊用 1~5 來表示每一個封包同時也代表封包順序。

  1. 通過不同尺寸的 malloc / free 來填滿 tcache,我們需要這些 tcache 當作洞洞之間的 barrier,因為 tcache chunk 上的 in-use bit 不會被清掉,剛好適合拿來做 barrier。
  2. 通過一系列的 malloc / free 堆出 27 個大小相間洞洞對(hole pairs)
  3. malloc 4KB 與 320B 的 Chunk,填上未來的殘留值後 free 掉
    • 佈置 race 時會使用到的殘餘 chunk header,用來使 remainder chunk 變成 overlap chunk
    • 在 small hole 的末端寫上 bypass glibc 檢查的 footer
    • 在 small hole 寫上偽造的 vtable 與 _codecvt 指標作為未來偽造出的 FILE structure
  4. malloc 出一個超大塊的 buffer(~256KB)把 unsorted bin 中的 chunk 都 flush 掉,使這些chunk 跑去對應的 bin 中
  5. 最終,也是 GracePeriod 的最後一刻時,使 sshd 做出 malloc(4kb), malloc(304)… 的成對操作,開始我們的 Race Condition 之旅,我們希望 SIGALRM 可以在 malloc(4KB) 的中間觸發,造成前述的連鎖反應。

封包 1~4 與部分封包 5 可以在 GracePeriod 前先送給 sshd,但是第 5 個封包的最後一個 byte 一定要等到 GracePeriod 要到的時候才送出,因為 sshd 收到第 5 個封包的最後一個 byte 就會開始 malloc/free 的操作,而我們希望 SIGALRM 可以在 malloc 操作中被觸發。

結語 #

這個漏洞利用鏈是我近期覺得利用手法最 fancy 的,尤其是將有限的 heap corruption 變成能夠 RCE 的過程相當精彩,非常像高難度 CTF 會遇到的 500 分題目。

回到實際利用面,現在多數機器都已經跑在 64-bit 下,因此 Weak ASLR 的影響不像是 32-bit 下的影響這麼大,所以 64-bit 的攻擊才會需要撞 base address 撞到昏天暗地。如果能有一個 information leak 的漏洞可以穩定取得 libc base address 的話,這個漏洞的利用就會變得相當的恐怖,也許野外的機器用不到幾天就被打光了。