| 항목 | 내용 |
|---|---|
| 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)
단계에서 서비스 거부는 물론 이론적으로는 원격 코드 실행까지 가능하다고 평가됐다.
유닉스 프로세스는 실행 도중 언제든 커널로부터 시그널을 받아 미리 등록해 둔 핸들러
함수로 즉시 점프할 수 있다. 문제는 이 점프가 프로그램의 어떤 지점에서든, 심지어
malloc()이 힙 메타데이터를 한창 수정하고 있는 도중에도 일어날 수 있다는 점이다. 만약
시그널 핸들러가 그 순간에 다시 malloc()이나 free()를 호출하면, 방금 중단된
malloc()/free()가 아직 마무리하지 못한 내부 자료구조(가령 free list의 포인터
연결)를 핸들러가 다시 건드리게 되어 힙이 일관성을 잃는다. 이런 이유로 POSIX는 시그널
핸들러 안에서 호출해도 안전하다고 보장하는 함수 목록(async-signal-safe 함수)을 별도로
정의해 두고 있으며, 여기에는 write(), _exit() 같은 극히 단순한 시스템 콜만 포함되고
malloc(), free(), printf(), 그리고 내부적으로 이들을 사용하는 syslog()는
포함되지 않는다.
sshd는 클라이언트가 접속하자마자 인증을 무한정 기다려주지 않는다. 연결이 시작되면
alarm(LoginGraceTime)으로 타이머를 걸어두고, 그 시간 안에 인증이 끝나지 않으면 커널이
SIGALRM을 보내 미리 등록해 둔 grace_alarm_handler()가 실행되어 연결을 끊는다. 인증에
성공하면 alarm(0)으로 타이머를 취소하므로 정상적인 연결에서는 이 핸들러가 절대 실행되지
않는다 — 오직 "인증에 실패했거나 아직 끝내지 못한 채로 시간이 다 된 경우"에만 호출된다는
점이 중요하다.
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이 도달하면:
- 메인 흐름은 힙 자료구조(예: free list)를 수정하는 중간 상태에 있다.
grace_alarm_handler()가 끼어들어fatal()을 호출하고, 이는 다시syslog()를 통해 또 다른malloc()/free()를 시도한다 — 이미 일관성이 깨진 힙 위에서.- 곧이어
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 쪽에 세 번째 패치가 더해졌다.
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 정리 코드에 진입조차 하지 않고 즉시 반환한다 —
메인 흐름이 무엇을 하고 있었든, 알람 핸들러가 그것을 다시 건드릴 방법 자체가 사라진다.
같은 날 커밋된 두 번째 패치는 "시그널 핸들러의 종료 경로를 더 짧게 만든다 — 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);
}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 릴리스에 포함되어 배포됐다.
이 DO_LOG_SAFE_IN_SIGHAND 가드는 2020년 OpenSSH 8.5p1의 로그 인프라 리팩터링
과정에서 실수로 사라졌고, 그 결과 2024년 7월 사실상 동일한 클래스의 취약점이
CVE-2024-6387("regreSSHion")로 다시 발견됐다. 이는 2006년 당시 Josh Bressers가 지적했던
"근본 문제는 GSSAPI가 아니라 시그널 핸들러 설계 자체"라는 진단이 옳았음을 뒷받침하는
사례로 볼 수 있다.
- OpenSSH 4.4 릴리스 노트 — "Fix an unsafe signal handler reported by Mark Dowd" 공식 언급. (검증됨, 1차 자료)
- OpenBSD CVS 동기화 커밋 — log.c/log.h/sshd.c (deraadt, 2006/08/18) —
sigdie()도입 diff 직접 확인. (검증됨, 1차 자료) - OpenBSD CVS 동기화 커밋 — auth.h/session.c/sshd.c (markus, 2006/08/18) —
authenticated플래그 도입 diff 직접 확인. (검증됨, 1차 자료) - portable OpenSSH 커밋 — DO_LOG_SAFE_IN_SIGHAND 도입 (djm) — 플랫폼별 안전성 가드 diff 직접 확인. (검증됨, 1차 자료)
- OpenSSH
V_4_3_P2태그와V_4_4_P1태그의sshd.c/log.c/fatal.c/session.c전체 diff 직접 대조(https://raw.githubusercontent.com/openssh/openssh-portable/V_4_3_P2/...,.../V_4_4_P1/...). (검증됨, 1차 자료) - NVD CVE-2006-5051 — 공식 CVE 설명, CVSS. (검증됨)
- Red Hat Bugzilla #208347 — Josh Bressers의 분석("GSSAPI 코드는 무해하며, 진짜 문제는 시그널 핸들러 설계"). (보조 자료, 검색 스니펫으로만 확인했으며 원문 전체를 직접 열람하지는 못함 — 아래 미해결 지점 참고)
- Qualys — regreSSHion(CVE-2024-6387) 기술 분석 — 2020년
DO_LOG_SAFE_IN_SIGHAND가드 소실 경위 및 동일 클래스 취약점 재발 설명. (검증됨, 2024년 재발 사건에 대한 보조 설명으로만 사용)
- Red Hat Bugzilla #208347 원문 페이지에 WebFetch로 직접 접근했으나 로그인/렌더링 제약으로 보이는 이유로 검색 스니펫 수준의 요약만 확인할 수 있었다. Josh Bressers 발언은 검색 결과에 인용된 텍스트를 그대로 옮긴 것이며, 전체 논의 맥락(다른 참여자들의 반박 여부 등)은 확인하지 못했다.
- "이중 해제가 정확히 어떤 객체(예:
gss_ctx_id_t의 어느 필드)에서 일어나는가"는 NVD/ MITRE 설명에도 "unspecified vectors"로만 되어 있고, 2006년 당시 공개된 별도의 상세 익스플로잇 문서나 PoC 분석 자료를 찾지 못했다. 본 리포트의 "근본 원인 ②" 절은 소스 diff로 확인된 코드 구조(정리 함수가 인증 완료 전에도 호출될 수 있었다는 사실, 그 정리 함수가 GSSAPI 컨텍스트를 해제한다는 사실)로부터 합리적으로 재구성한 메커니즘이며, 특정 줄 번호 단위의 이중 해제 지점을 1차 자료로 확정하지는 못했다.