Skip to content

Latest commit

 

History

History
341 lines (283 loc) · 18.8 KB

File metadata and controls

341 lines (283 loc) · 18.8 KB

OpenSSH sshd의 로그인 유예시간 알람 시그널 핸들러 경쟁 조건 (CVE-2006-5051)

메타데이터

항목 내용
CVE ID CVE-2006-5051
영향 버전 OpenSSH 4.4 이전 전 버전
CWE CWE-415 (Double Free)
CVSS v3.1 8.1 (High) / v2.0 9.3 (High) — NVD 소급 평가
공개일 2006-09-27 (OpenSSH 4.4 릴리스와 함께 공개)
발견자 Mark Dowd
수정 커밋 OpenBSD CVS sshd.c 1.346/1.347, log.c/log.h 1.39 (2006-08-18); OpenSSH-portable bb59814 (DO_LOG_SAFE_IN_SIGHAND 도입)

개요

sshd는 클라이언트가 LoginGraceTime 안에 인증을 마치지 못하면 SIGALRM을 받아 연결을 강제 종료한다. 그런데 이 알람을 처리하는 시그널 핸들러가 시그널 핸들러 안에서 호출해서는 안 되는(async-signal-unsafe) 함수들을 태연히 호출하고 있었다. 구체적으로는 두 가지 문제가 겹쳐 있었다. 첫째, 알람 핸들러가 fatal()을 호출했는데, 이 함수는 내부적으로 syslog()(그리고 이를 지원하는 malloc()/free())를 호출한다 — 이 함수들은 힙 잠금이나 정적 버퍼를 다루기 때문에, 메인 실행 흐름이 마침 그 함수들 안에 있을 때 시그널이 끼어들면 힙 상태가 꼬일 수 있다. 둘째, 그 알람 핸들러는 결국 cleanup_exit()do_cleanup()을 호출해 GSSAPI/Kerberos/PAM 자격증명을 해제하는데, 이때의 가드 조건이 "인증이 실제로 끝났는지"가 아니라 "인증 컨텍스트 구조체가 만들어져 있는지"만 확인하고 있었다. 그 결과 **아직 인증이 끝나지 않은(따라서 GSSAPI 컨텍스트 등이 한창 만들어지거나 정리되는 중일 수 있는) 연결에서 유예시간이 끝나버리면, 이미 진행 중인 정리 작업과 알람 핸들러가 촉발한 정리 작업이 같은 메모리를 두 번 건드리는 이중 해제(double free)**로 이어질 수 있었다. 원격 공격자는 인증을 절대 성공시키지 않고 시간만 끌다가 정확한 타이밍에 유예시간을 만료시켜 이 경쟁을 유발할 수 있어, 사전 인증(pre-auth) 단계에서 서비스 거부는 물론 이론적으로는 원격 코드 실행까지 가능하다고 평가됐다.

사전 지식

시그널과 "비동기 시그널 안전(async-signal-safe)" 함수

유닉스 프로세스는 실행 도중 언제든 커널로부터 시그널을 받아 미리 등록해 둔 핸들러 함수로 즉시 점프할 수 있다. 문제는 이 점프가 프로그램의 어떤 지점에서든, 심지어 malloc()이 힙 메타데이터를 한창 수정하고 있는 도중에도 일어날 수 있다는 점이다. 만약 시그널 핸들러가 그 순간에 다시 malloc()이나 free()를 호출하면, 방금 중단된 malloc()/free()가 아직 마무리하지 못한 내부 자료구조(가령 free list의 포인터 연결)를 핸들러가 다시 건드리게 되어 힙이 일관성을 잃는다. 이런 이유로 POSIX는 시그널 핸들러 안에서 호출해도 안전하다고 보장하는 함수 목록(async-signal-safe 함수)을 별도로 정의해 두고 있으며, 여기에는 write(), _exit() 같은 극히 단순한 시스템 콜만 포함되고 malloc(), free(), printf(), 그리고 내부적으로 이들을 사용하는 syslog()포함되지 않는다.

OpenSSH의 로그인 유예시간(LoginGraceTime)과 알람 핸들러

sshd는 클라이언트가 접속하자마자 인증을 무한정 기다려주지 않는다. 연결이 시작되면 alarm(LoginGraceTime)으로 타이머를 걸어두고, 그 시간 안에 인증이 끝나지 않으면 커널이 SIGALRM을 보내 미리 등록해 둔 grace_alarm_handler()가 실행되어 연결을 끊는다. 인증에 성공하면 alarm(0)으로 타이머를 취소하므로 정상적인 연결에서는 이 핸들러가 절대 실행되지 않는다 — 오직 "인증에 실패했거나 아직 끝내지 못한 채로 시간이 다 된 경우"에만 호출된다는 점이 중요하다.

OpenSSH의 로그 함수 계층과 fatal()

OpenSSH는 로그 레벨별로 debug(), verbose(), error(), fatal() 같은 함수들을 제공하는데, 이들은 모두 내부적으로 do_log()라는 공통 함수를 호출한다(log.c). do_log()는 메시지를 포맷팅한 뒤, log_on_stderr가 아니면 openlog()/syslog()/ closelog()를 호출해 syslog 데몬에 메시지를 보낸다:

/* log.c, do_log() 마지막 부분 */
if (log_on_stderr) {
    ...
    write (STDERR_FILENO, msgbuf, strlen (msgbuf));
} else {
    openlog (argv0 ? argv0 : __progname, LOG_PID, log_facility);
    syslog (pri, "%.500s", fmtbuf);
    closelog ();
}

그리고 fatal()(fatal.c)은 이 do_log()를 호출한 뒤 cleanup_exit(255)를 호출해서 프로세스를 종료한다:

void
fatal (const char *fmt, ...)
{
  va_list args;
  va_start (args, fmt);
  do_log (SYSLOG_LEVEL_FATAL, fmt, args);
  va_end (args);
  cleanup_exit (255);
}

문제의 4.3 버전 grace_alarm_handler()는 바로 이 fatal()을 호출하고 있었다 — 개발자들 스스로도 이게 문제라는 것을 알고 있었다는 흔적이 코드에 그대로 남아 있다.

/* sshd.c (4.3), grace_alarm_handler() */
static void
grace_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 ());
}

즉 유예시간이 끝나는 순간 언제든 SIGALRM이 날아들 수 있고, 그 핸들러가 fatal()do_log()syslog()(내부적으로 malloc/free 사용)를 호출한다는 것이, 이미 코드 작성자들도 "고칠 방법을 모르겠다"고 주석으로 남길 만큼 잘 알려진 위험이었다.

취약점 분석

근본 원인 ①: 인증 완료 여부가 아니라 "구조체 존재 여부"로 정리 작업을 게이팅

fatal()cleanup_exit()은 다음과 같이 the_authctxt(현재 연결의 인증 컨텍스트)가 있으면 do_cleanup()을 호출한다(sshd.c):

void
cleanup_exit (int i)
{
  if (the_authctxt)
    do_cleanup (the_authctxt);
  ...
  _exit (i);
}

그리고 4.3 버전의 do_cleanup()(session.c)은 이렇게 되어 있었다:

void
do_cleanup (Authctxt *authctxt)
{
  static int called = 0;
  if (is_child) return;
  if (called) return;        /* 같은 프로세스에서 두 번 도는 것만 막음 */
  called = 1;

  if (authctxt == NULL)      /* <- 4.3: 구조체가 "존재하기만" 하면 통과 */
    return;
#ifdef KRB5
  if (options.kerberos_ticket_cleanup && authctxt->krb5_ctx)
    krb5_cleanup_proc (authctxt);
#endif
#ifdef GSSAPI
  if (compat20 && options.gss_cleanup_creds)
    ssh_gssapi_cleanup_creds ();
#endif
  ...
}

the_authctxt는 클라이언트가 접속하자마자, 인증이 성공하기 한참 전부터 이미 할당되어 전역 포인터에 채워진다. 즉 이 시점의 검사(authctxt == NULL)는 "인증이 끝났는가"를 전혀 구분하지 못하고, "구조체가 만들어지긴 했는가"만 확인한다. 문제는 LoginGraceTime이 끝나는 순간이 바로 "아직 인증 중"인 순간이라는 점이다 — 다시 말해 grace_alarm_handler()가 실행될 수 있는 유일한 시점(=인증 미완료)이 하필 do_cleanup()이 GSSAPI/Kerberos/PAM 정리를 수행하는 조건과 전혀 상충하지 않았다.

근본 원인 ②: 정리 작업 도중 끼어드는 시그널

GSSAPI를 사용하는 인증 시도는 여러 라운드에 걸쳐 컨텍스트 객체를 만들고 부분적으로 해제하는 과정을 거칠 수 있다. 만약 메인 실행 흐름이 인증 실패 처리 과정에서 이미 GSSAPI 관련 자료구조를 정리하는 도중에(예: free() 호출 한가운데) 정확히 SIGALRM이 도달하면:

  1. 메인 흐름은 힙 자료구조(예: free list)를 수정하는 중간 상태에 있다.
  2. grace_alarm_handler()가 끼어들어 fatal()을 호출하고, 이는 다시 syslog()를 통해 또 다른 malloc()/free()를 시도한다 — 이미 일관성이 깨진 힙 위에서.
  3. 곧이어 cleanup_exit()do_cleanup()이 호출되는데, authctxt == NULL 검사만으로는 막히지 않으므로 ssh_gssapi_cleanup_creds()메인 흐름이 아직 다 정리하지 못한(혹은 이미 정리했지만 called 플래그가 그 부분 실행을 막지 못하는) 같은 객체를 다시 한 번 해제하려 시도한다.

NVD/MITRE의 공식 설명이 "unspecified vectors that lead to a double-free"라며 구체적인 트리거를 명시하지 않은 것은, 이 문제가 특정 한 줄의 버그가 아니라 "인증 미완료 상태에서 언제든 끼어들 수 있는 비동기 시그널 핸들러가, 원래는 인증 완료 후 단 한 번만 실행되도록 설계된 정리 경로 전체를 강제로 재실행시킨다"는 구조적 결함이기 때문으로 보인다. 실제로 당시 Red Hat의 보안 엔지니어 Josh Bressers는 사내 버그 트래커에서 "GSSAPI 코드 자체는 무해하다 — 업스트림이 이걸 GSSAPI 문제라고 부르는 것은 제대로 분석하지 않았다는 뜻"이라고 지적하며, 문제의 본질이 GSSAPI가 아니라 시그널 핸들러의 전반적인 설계에 있다고 주장했다.

   [정상 흐름 (인증 성공)]                    [경쟁 조건 발생 흐름 (인증 미완료 + 유예시간 만료)]

   accept() 후 alarm(LoginGraceTime)          accept() 후 alarm(LoginGraceTime)
        │                                            │
        ▼                                            ▼
   the_authctxt 할당 (아직 미인증)              the_authctxt 할당 (아직 미인증)
        │                                            │
        ▼                                            ▼
   GSSAPI 컨텍스트 생성/부분 정리 반복          GSSAPI 컨텍스트 정리 함수 실행 중
        │                                            │  (힙 free list 수정 중간 상태)
        ▼                                            │
   인증 성공                                          │◄──────────── SIGALRM 도달
        │                                            ▼
   alarm(0); authctxt->authenticated = 1 (4.4~)  grace_alarm_handler()
        │  (4.3에는 이 플래그 자체가 없음)              │  fatal() → do_log() → syslog()
        ▼                                            │  (내부 malloc/free, 중단된 힙 위에서 실행)
   세션 시작, do_cleanup은 로그아웃 시 정상 실행       ▼
                                                cleanup_exit() → do_cleanup(the_authctxt)
                                                     │  authctxt != NULL 이므로 통과 (4.3)
                                                     ▼
                                                ssh_gssapi_cleanup_creds() 재실행
                                                     │
                                                     ▼
                                          이미 해제 중이던 객체를 다시 free() → 이중 해제

수정 방법

수정은 2006년 8월 18일 OpenBSD CVS에 커밋된 두 개의 서로 다른, 그러나 상호 보완적인 패치로 이루어졌고, portable OpenSSH 쪽에 세 번째 패치가 더해졌다.

패치 ① — 정리 작업을 "인증이 실제로 끝난 뒤"로 지연 (markus@, sshd.c/session.c/auth.h)

Authctxt 구조체에 authenticated 필드를 새로 추가하고, 인증이 성공해 알람이 이미 취소된 바로 그 지점에서만 이 플래그를 1로 세팅한다(sshd.c):

  alarm(0);
  signal(SIGALRM, SIG_DFL);
+ authctxt->authenticated = 1;

그리고 do_cleanup()의 가드 조건에 이 플래그를 추가한다(session.c):

- if (authctxt == NULL)
+ if (authctxt == NULL || !authctxt->authenticated)
    return;

이 한 줄이 근본 원인 ①을 직접 없앤다: grace_alarm_handler()는 정의상 인증이 끝나지 않았을 때만 호출되므로, 이 시점의 authctxt->authenticated는 항상 0이다. 따라서 do_cleanup()은 GSSAPI/Kerberos/PAM 정리 코드에 진입조차 하지 않고 즉시 반환한다 — 메인 흐름이 무엇을 하고 있었든, 알람 핸들러가 그것을 다시 건드릴 방법 자체가 사라진다.

패치 ② — 시그널 핸들러의 종료 경로를 최대한 짧게 (deraadt@, log.c/log.h/sshd.c)

같은 날 커밋된 두 번째 패치는 "시그널 핸들러의 종료 경로를 더 짧게 만든다 — Mark Dowd가 지적한 위험한 코드"라는 커밋 메시지와 함께, fatal() 대신 새로 만든 sigdie()를 알람 핸들러에서 쓰도록 바꿨다:

  static void
  grace_alarm_handler (int sig)
  {
    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 ());
+   sigdie ("Timeout before authentication for %s", get_remote_ipaddr ());
  }

sigdie()(log.c)는 다음과 같이, 로그를 남기는 부분을 조건부로 만들고 마지막은 반드시 _exit()로 끝난다:

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

패치 ③ — portable OpenSSH: "안전하다고 증명된 플랫폼만" 로깅 허용

OpenBSD 자체는 syslog_r()이라는, 이름 그대로 재진입 가능한(async-signal-safe) syslog 구현을 갖고 있어 DO_LOG_SAFE_IN_SIGHAND를 정의해도 안전하다. 하지만 리눅스를 포함한 대부분의 플랫폼은 그런 안전한 버전이 없다. 그래서 portable OpenSSH 관리자(djm)는 같은 날 별도 커밋으로 "거의 모든 플랫폼에서 시그널 핸들러 안 syslog를 비활성화한다"는 메시지와 함께 configure.ac/defines.h에서 OpenBSD를 제외한 모든 곳에서 DO_LOG_SAFE_IN_SIGHAND가 정의되지 않도록 만들었다. 그 결과 리눅스 등에서는 sigdie()가 로그를 아예 남기지 않고 곧바로 _exit(1)만 호출하게 된다 — 근본 원인 ②(비동기 시그널-불안전 함수 호출)를 완전히 제거하는 셈이다.

   [패치 후 (리눅스 등 대부분 플랫폼)]

   SIGALRM 도달 (인증 미완료 상태, 힙이 어떤 상태이든 상관없이)
        │
        ▼
   grace_alarm_handler()
        │
        ▼
   sigdie(...)
        │  #ifdef DO_LOG_SAFE_IN_SIGHAND 미정의 (OpenBSD 제외 전부)
        │  → do_log()/syslog()/malloc()/free() 호출 자체가 없음
        ▼
   _exit(1)   (async-signal-safe, 힙에 전혀 접근하지 않음)

   ※ cleanup_exit()/do_cleanup() 경로 자체를 타지 않으므로
     패치 ①의 authenticated 플래그 검사에 도달할 필요조차 없어짐

세 패치는 서로 다른 계층에서 같은 문제를 막는다 — ①은 "정리 작업이 실행되는 조건"을 좁히고, ②·③은 "정리 작업으로 가는 경로에 진입하기 전, 시그널 핸들러 자체가 안전하지 않은 함수를 호출하지 않도록" 만든다. 이 패치들은 그대로 OpenSSH 4.4 릴리스에 포함되어 배포됐다.

18년 뒤의 재발: CVE-2024-6387 "regreSSHion"

DO_LOG_SAFE_IN_SIGHAND 가드는 2020년 OpenSSH 8.5p1의 로그 인프라 리팩터링 과정에서 실수로 사라졌고, 그 결과 2024년 7월 사실상 동일한 클래스의 취약점이 CVE-2024-6387("regreSSHion")로 다시 발견됐다. 이는 2006년 당시 Josh Bressers가 지적했던 "근본 문제는 GSSAPI가 아니라 시그널 핸들러 설계 자체"라는 진단이 옳았음을 뒷받침하는 사례로 볼 수 있다.

참고 자료

미해결/불확실 지점

  • Red Hat Bugzilla #208347 원문 페이지에 WebFetch로 직접 접근했으나 로그인/렌더링 제약으로 보이는 이유로 검색 스니펫 수준의 요약만 확인할 수 있었다. Josh Bressers 발언은 검색 결과에 인용된 텍스트를 그대로 옮긴 것이며, 전체 논의 맥락(다른 참여자들의 반박 여부 등)은 확인하지 못했다.
  • "이중 해제가 정확히 어떤 객체(예: gss_ctx_id_t의 어느 필드)에서 일어나는가"는 NVD/ MITRE 설명에도 "unspecified vectors"로만 되어 있고, 2006년 당시 공개된 별도의 상세 익스플로잇 문서나 PoC 분석 자료를 찾지 못했다. 본 리포트의 "근본 원인 ②" 절은 소스 diff로 확인된 코드 구조(정리 함수가 인증 완료 전에도 호출될 수 있었다는 사실, 그 정리 함수가 GSSAPI 컨텍스트를 해제한다는 사실)로부터 합리적으로 재구성한 메커니즘이며, 특정 줄 번호 단위의 이중 해제 지점을 1차 자료로 확정하지는 못했다.