在今年(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 可以長成這樣。
當 SIGALRM 呼叫前,我們 malloc 一塊 4KB 的 chunk,使得 8KB 的 chunk 被分成兩塊 4KB 的 chunk:
此時我們使用先前提及的通過 SIGALRM 中斷 malloc()
中 set_head 的過程,使 remainder chunk 變得異常的大,足以 overlap 到後方的 chunk(上下兩行對應到的是同個記憶體空間,解讀方式不同):
接著 __tzfile_read()
時,fopen 會 malloc 出 FILE structure:
接著 __fread_unlocked
也會 malloc 出一塊 4KB 的 read buffer。此時 4KB 的 read buffer allocation 將會破壞 heap 完整性,通過控制好 remainder chunk 的位置,使 remainder chunk 的 header->bk
剛好蓋掉 FILE structure 上的 vtable_offset
欄位(1 Byte):
最終我們通過控制 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 示意圖:
這裏作者提出使用 sshd 的 public-key parsing 來作為 heap primitive 來完成任意大小的 heap malloc/free 操作,分別是 cert_parse
與 cert_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 來表示每一個封包同時也代表封包順序。
- 通過不同尺寸的 malloc / free 來填滿 tcache,我們需要這些 tcache 當作洞洞之間的 barrier,因為 tcache chunk 上的 in-use bit 不會被清掉,剛好適合拿來做 barrier。
- 通過一系列的 malloc / free 堆出 27 個大小相間洞洞對(hole pairs)
- malloc 4KB 與 320B 的 Chunk,填上未來的殘留值後 free 掉
- 佈置 race 時會使用到的殘餘 chunk header,用來使 remainder chunk 變成 overlap chunk
- 在 small hole 的末端寫上 bypass glibc 檢查的 footer
- 在 small hole 寫上偽造的 vtable 與
_codecvt
指標作為未來偽造出的 FILE structure
- malloc 出一個超大塊的 buffer(~256KB)把 unsorted bin 中的 chunk 都 flush 掉,使這些chunk 跑去對應的 bin 中
- 最終,也是 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 的話,這個漏洞的利用就會變得相當的恐怖,也許野外的機器用不到幾天就被打光了。