Skip to content

Latest commit

 

History

History
210 lines (159 loc) · 18.2 KB

File metadata and controls

210 lines (159 loc) · 18.2 KB

MySQL 클라이언트 mysql_real_connect()가 DNS 응답의 h_length를 검증 없이 믿고 고정 크기 소켓 주소 버퍼에 memcpy하는 문제 (CVE-2004-0836)

메타데이터

항목 내용
CVE ID CVE-2004-0836
영향 소프트웨어 MySQL 3.x (3.23.49 이전), 4.x (4.0.21 이전) — 클라이언트 라이브러리 libmysql/libmysql.cmysql_real_connect()
CWE CWE-119 (Improper Restriction of Operations within the Bounds of a Memory Buffer)
CVSS v2 (NVD) 10.0 CRITICAL — AV:N/AC:L/Au:N/C:C/I:C/A:C
버그 신고 Lukasz Wojtow, 2004-06-04, MySQL 공식 버그 트래커 Bug#4017
실제 수정 Guilhem Bichot, 2004-06-17, mysql-3.23 브랜치 커밋 69bea44, 이후 mysql-4.0으로 머지
CVE 공개(NVD) 2004-11-03

개요

MySQL 클라이언트 라이브러리(libmysql)의 mysql_real_connect()는 사용자가 넘긴 호스트명이 IP 리터럴이 아니면 이를 IP 주소로 바꾸기 위해 DNS 조회 함수(my_gethostbyname_r)를 호출한다. 이 함수가 돌려주는 struct hostent에는 해석된 주소 데이터(h_addr)와 그 길이(h_length)가 들어 있는데, mysql_real_connect()는 이 h_length가 목적지 버퍼 크기와 맞는지 전혀 검사하지 않은 채 memcpy(&sock_addr.sin_addr, hp->h_addr, (size_t) hp->h_length)를 그대로 실행한다. sock_addr.sin_addr는 IPv4 주소 4바이트만 담는 고정 크기 필드이므로, h_length가 4보다 큰 값으로 채워지면 그만큼 스택의 인접 메모리가 덮어써진다.

이 값(h_length)은 궁극적으로 DNS 서버가 보낸 응답에서 유래한다. 즉 클라이언트가 접속하려는 호스트명의 DNS를 공격자가 통제하고 있다면(자신이 소유한 도메인을 가리키게 하거나, 캐시 포이즈닝 등으로), 공격자는 자신의 네임서버가 돌려주는 응답을 조작해 이 오버플로우를 유발할 시도를 할 수 있다. 신고자는 특히 PHP처럼 스크립트가 접속할 MySQL 호스트명을 지정할 수 있는 환경에서, 이 버그가 safe_mode/open_basedir 같은 샌드박스를 우회하는 데 쓰일 수 있다고 지적했다. NVD는 이를 원격의 DNS 서버가 트리거하는 문제로 분류하고 CVSS v2 10.0(완전한 기밀성·무결성·가용성 침해)을 부여했다.

사전 지식

클라이언트가 서버에 접속하기 전 거치는 이름 해석 단계

mysql_real_connect(mysql, host, ...)는 TCP 소켓을 만들어 MySQL 서버에 연결하기 전에, 먼저 host 문자열이 무엇을 가리키는지 알아내야 한다. 코드는 두 경로로 나뉜다.

  1. host"192.0.2.10"처럼 이미 점-십진 표기 IP 리터럴이면 inet_addr()로 바로 32비트 정수 주소를 얻는다.
  2. 그렇지 않고 "db.example.com"처럼 도메인 이름이면, DNS 조회를 통해 이름을 IP 주소로 바꿔야 한다. 이 경로에서 my_gethostbyname_r()이 호출된다.

이 리포트가 다루는 버그는 두 번째 경로, DNS로 해석한 결과를 소켓 주소 구조체에 옮겨 담는 지점에서 발생한다.

struct hostentgethostbyname 계열 API

유닉스 계열 시스템에서 이름 해석 결과는 전통적으로 <netdb.h>에 정의된 struct hostent로 표현된다.

struct hostent {
    char  *h_name;       /* 공식 호스트명 */
    char **h_aliases;    /* 별칭 목록 */
    int    h_addrtype;   /* AF_INET, AF_INET6 등 주소 체계 */
    int    h_length;     /* 주소 하나의 바이트 길이 (IPv4면 보통 4) */
    char **h_addr_list;  /* 해석된 주소들의 배열 */
};
#define h_addr h_addr_list[0]   /* 첫 번째 주소에 대한 관용적 매크로 */

h_addrtype이 무엇인지에 따라 h_length가 정해진다 — IPv4(AF_INET) 주소는 4바이트, IPv6(AF_INET6)는 16바이트다. 문제는 이 h_length 값 자체가 "해석기(resolver)가 DNS 응답을 파싱해서 채워 넣는 필드"라는 점이다. 원래의 gethostbyname()은 스레드 세이프하지 않다(내부 정적 버퍼를 반환하므로 여러 스레드가 동시에 호출하면 결과가 섞인다). 이를 스레드 세이프하게 만든 것이 gethostbyname_r() 계열 함수들인데, 문제는 이 계열 함수의 호출 규약이 플랫폼마다 다르다는 것이다 — glibc 스타일(결과 포인터를 별도 out-파라미터로 받음), Solaris 스타일(반환값 하나), hostent_data 버퍼를 요구하는 스타일 등이 뒤섞여 있었다.

MySQL의 my_gethostbyname_r 래퍼

MySQL은 이 플랫폼별 차이를 감추기 위해 mysys/my_gethostbyname.c에 자체 래퍼 my_gethostbyname_r()을 두고, 컴파일 타임에 어떤 스타일을 쓸지 매크로로 분기했다(당시 소스를 그대로 인용):

/* glibc2 스타일 */
struct hostent *my_gethostbyname_r(const char *name,
                                   struct hostent *result, char *buffer,
                                   int buflen, int *h_errnop)
{
  struct hostent *hp;
  dbug_assert((size_t) buflen >= sizeof(*result));
  if (gethostbyname_r(name,result, buffer, (size_t) buflen, &hp, h_errnop))
    return 0;
  return hp;
}

/* gethostbyname_r()이 아예 없는 플랫폼: 뮤텍스로 감싼 gethostbyname() 그대로 사용 */
struct hostent *my_gethostbyname_r(const char *name,
                                   struct hostent *result, char *buffer,
                                   int buflen, int *h_errnop)
{
  struct hostent *hp;
  pthread_mutex_lock(&LOCK_gethostbyname_r);
  hp= gethostbyname(name);
  *h_errnop= h_errno;
  return hp;
}

즉 어떤 분기를 타든 최종적으로 돌려주는 것은 결국 시스템 리졸버가 채운 struct hostent *이고, 그 안의 h_length가 얼마인지는 호출 측(MySQL) 입장에서는 전적으로 리졸버 구현이 정한 값을 그대로 신뢰해야 하는 구조다. 신고자는 버그 리포트에서 "DNS 응답 자체에 주소 길이 필드가 들어 있으며(RFC 1035), 이를 그대로 h_length에 반영하는 리졸버 구현이 존재할 수 있다"고 지적했다 — 즉 근본적으로 h_length는 "DNS 서버가 말한 값"에서 유래하는 신뢰 경계 바깥의 입력이라는 것이다.

struct sockaddr_in과 4바이트로 고정된 sin_addr

반대로 memcpy의 목적지인 sock_addr.sin_addr는 얘기가 다르다. mysql_real_connect()는 접속용 소켓 주소를 다음처럼 IPv4 전용 구조체로 선언한다.

struct sockaddr_in sock_addr;   /* mysql_real_connect() 지역 변수 */

struct sockaddr_insin_addr 필드는 struct in_addr 하나, 즉 IPv4 주소 정확히 4바이트만 담을 수 있는 고정 크기 필드다(리눅스/BSD 계열에서 통상 sin_family(2B) + sin_port(2B) + sin_addr(4B) + sin_zero(8B 패딩)로 총 16바이트). 이 크기는 컴파일 타임에 정해지며 런타임에 늘어나지 않는다.

정리하면: 소스(DNS 응답에서 유래한 h_length, 공격자가 값을 결정할 수 있는 영역)와 목적지(sin_addr, 항상 4바이트로 고정된 영역) 사이에 크기 검증이 없다는 것이 이 버그의 본질이다.

취약점 분석

취약한 코드 — libmysql/libmysql.cmysql_real_connect()

수정 직전 소스(커밋 2e04da0, mysql_real_connect() 내부, 실제 원문 그대로)는 다음과 같다.

    if ((int) (ip_addr = inet_addr(host)) != (int) INADDR_NONE)
    {
      memcpy_fixed(&sock_addr.sin_addr,&ip_addr,sizeof(ip_addr));   // [A] IP 리터럴 경로: 크기 고정, 안전
    }
    else
    {
      int tmp_errno;
      struct hostent tmp_hostent,*hp;
      char buff2[GETHOSTBYNAME_BUFF_SIZE];
      hp = my_gethostbyname_r(host,&tmp_hostent,buff2,sizeof(buff2),
			      &tmp_errno);                              // [B] DNS 조회
      if (!hp)
      {
	net->last_errno=CR_UNKNOWN_HOST;
	sprintf(net->last_error, ER(CR_UNKNOWN_HOST), host, tmp_errno);
	my_gethostbyname_r_free();
	goto error;
      }
      memcpy(&sock_addr.sin_addr,hp->h_addr, (size_t) hp->h_length);  // [C] 검증 없는 memcpy
      my_gethostbyname_r_free();
    }

[A] IP 리터럴 경로는 sizeof(ip_addr)처럼 복사할 크기를 목적지 쪽 상수로 고정해서 애초에 문제가 없다. 반면 [C] DNS 경로는 복사 길이를 hp->h_length, 즉 [B]에서 리졸버가 채워 넣은, 목적지와 무관한 값으로 쓴다. sock_addr.sin_addr는 4바이트인데, h_length가 이보다 크면 memcpy는 그만큼 sock_addr 구조체의 뒤쪽 필드(sin_zero 등)를 지나 스택의 다음 지역 변수까지 이어서 덮어쓴다.

텍스트로 그린 그림 — 패치 전: h_length만큼 그대로 흘러넘치는 memcpy

정상 케이스 (IPv4 A 레코드 응답, h_length = 4)

  hp->h_addr  [ 93][184][216][ 34]                (4바이트)
                 |    |    |    |
                 v    v    v    v
  sock_addr.sin_addr [ 93][184][216][ 34]  <- 딱 4바이트, sin_zero 이하는 그대로
                     +----------------+--------+
                     | sin_addr (4B) | sin_zero(8B, 패딩) |  <- 다음 지역 변수로 이어짐
                     +----------------+--------+

공격자가 통제하는 DNS 응답 (h_length = 64, 예시)

  hp->h_addr  [ .. 공격자가 채운 64바이트 임의 데이터 .. ]
                 |
                 v  memcpy(dst=&sock_addr.sin_addr, src=hp->h_addr, len=64)
  +----------------+--------+---------------------------------+
  | sin_addr (4B)  | sin_zero(8B) | pkt_length, net(NET*) 등   |  <- sock_addr 이후
  +----------------+--------+---------------------------------+     선언된 지역 변수까지
        ^________________________써야 할 4바이트를 훨씬 넘어_______________________^
        복사가 계속 진행되며 인접 스택 메모리를 공격자 데이터로 덮어씀

mysql_real_connect()의 지역 변수 선언 순서를 보면 sock_addr 바로 뒤에 pkt_length(연결 이후 패킷 길이 계산에 쓰임)와 NET *net(소켓/버퍼 상태를 가리키는 포인터, 함수 앞부분에서 net = &mysql->net로 이미 초기화되어 있음) 등이 선언되어 있다 — 다만 실제 컴파일된 바이너리에서 이 변수들이 소스상 선언 순서 그대로 스택에 배치된다는 보장은 없다(컴파일러·최적화 옵션에 따라 재배치될 수 있다). 확실한 것은 오버플로우가 "얼마나 멀리까지" 번지는지가 오직 공격자가 DNS 응답에 실어 보내는 h_length/h_addr 값에 달려 있다는 점이며, 이는 스택 카나리 우회나 저장된 레지스터·리턴 주소 훼손으로 이어질 잠재력을 가진 전형적인 스택 버퍼 오버플로우 패턴이다.

실제 재현 가능성에 대한 당시 논쟁 — 리졸버 구현에 좌우되는 문제

버그 리포트 코멘트를 보면 흥미로운 지점이 있다. MySQL 개발자 Matthew Lord는 "아주 긴 IP 주소로 시도해봤지만 오버런을 재현하지 못했다"고 답했고, 신고자 Lukasz Wojtow는 다음과 같이 반박했다(원문 그대로 인용):

"glibc에는 IP 주소를 4바이트로만 제한하는 제약이 있다(당연히). 하지만 일반적으로 말하면 주소 길이는 DNS 쿼리 응답에 실려 오고(농담처럼 들리겠지만 못 믿겠으면 RFC 1035를 보라), 이 길이를 그대로 h_length에 넣는 gethostbyname 구현을 가진 라이브러리에서는 이 버그가 발생할 수 있다. ... 이건 Linux(glibc)와 OpenBSD에서는 익스플로잇 불가능하지만, 다른 라이브러리에서는 그렇지 않다."

glibc/OpenBSD의 리졸버 구현 자체가 h_length를 주소 체계(AF_INET)에 맞춰 4로 강제하는 경향이 있어, 이 두 플랫폼에서는 실제로 재현하기 어려웠다는 것이 당시 관찰이었다. 그럼에도 담당 개발자 Guilhem Bichot는 "이론적으로 오버플로우가 가능하며, 크기 검사를 추가해서 나쁠 것이 없다"는 결론으로 수정을 진행했다 — 즉 이 수정은 "특정 리졸버 구현이 안전하게 동작해 줄 것"이라는 가정에 기대지 않고, MySQL 코드 스스로 목적지 버퍼 크기를 지키도록 방어선을 한 겹 추가한 것이다.

수정 방법

2004-06-17, Guilhem Bichot가 mysql-3.23 브랜치(이후 mysql-4.0으로 머지)에 다음 한 줄짜리 수정을 커밋했다(커밋 69bea44, gh api repos/mysql/mysql-server/commits/69bea44bc905로 직접 확인).

--- a/libmysql/libmysql.c
+++ b/libmysql/libmysql.c
@@ -1358,7 +1358,8 @@ mysql_real_connect(MYSQL *mysql,const char *host, const char *user,
 	my_gethostbyname_r_free();
 	goto error;
       }
-      memcpy(&sock_addr.sin_addr,hp->h_addr, (size_t) hp->h_length);
+      memcpy(&sock_addr.sin_addr, hp->h_addr,
+             min(sizeof(sock_addr.sin_addr), (size_t) hp->h_length));
       my_gethostbyname_r_free();
     }
     sock_addr.sin_port = (ushort) htons((ushort) port);

복사 길이를 hp->h_length 그대로 쓰는 대신, sizeof(sock_addr.sin_addr)(목적지 필드의 실제 크기, 4바이트)와 h_length더 작은 값을 쓰도록 min()으로 감쌌다. 즉 DNS 응답이 아무리 큰 길이를 주장하더라도, 실제로 복사되는 바이트 수는 목적지 필드 크기를 절대 넘지 않는다. 이 패치는 3.23 브랜치에서 3.23.49로, 4.0 브랜치에서는 3.23의 수정이 병합되며 4.0.21로 반영되었다(NVD 취약 버전 범위 3.20 ~ 3.23.49 미만, 4.0.0 ~ 4.0.21 미만과 일치). 담당자는 코드 전체를 검토해 동일 패턴(h_length를 검증 없이 memcpy 길이로 쓰는 코드)이 다른 곳에는 없음을 확인했다고 코멘트에 남겼다.

텍스트로 그린 그림 — 패치 후: 목적지 크기로 상한을 거는 memcpy

공격자가 통제하는 DNS 응답 (h_length = 64, 예시)

  hp->h_addr  [ .. 공격자가 채운 64바이트 임의 데이터 .. ]
                 |
                 v  len = min(sizeof(sin_addr)=4, h_length=64) = 4
  +----------------+--------+---------------------------------+
  | sin_addr (4B)  | sin_zero(8B) | pkt_length, net(NET*) 등   |
  +----------------+--------+---------------------------------+
        ^__딱 4바이트만 복사, 그 이후 바이트는 전부 버려짐(memcpy 자체가 호출되지 않음)__^
        sock_addr 뒤의 스택 영역은 더 이상 건드리지 않음

min()이 목적지 크기 4바이트를 고르므로, h_length가 4보다 큰 어떤 값을 주장하든 실제 memcpy 호출은 정확히 4바이트에서 멈춘다. 다만 이는 "결과 IP 주소가 4바이트를 넘는 부분은 조용히 잘려 버려진다"는 뜻이기도 하다 — IPv4 전용 코드 경로이므로 정상적인 4바이트 응답에는 아무 영향이 없다.

참고 자료

  • NVD CVE-2004-0836 — CVSS v2 10.0(AV:N/AC:L/Au:N/C:C/I:C/A:C), CWE-119, 영향 버전 범위(3.203.23.49 미만, 4.0.04.0.21 미만), 공개일 2004-11-03. (검증됨)
  • MySQL 공식 버그 트래커 Bug#4017 "mysql_real_connect buffer overflow" — 신고자(Lukasz Wojtow)의 최초 신고 원문(취약 코드 인용 포함), 개발자(Matthew Lord)와의 재현 가능성 논쟁, 담당자(Guilhem Bichot)의 수정 코멘트와 실제 fix 코드·ChangeSet ID(1.1424.3.1, 3.23 브랜치) 전문. (검증됨, 1차 자료 — 벤더 버그 트래커 원문)
  • 수정 커밋 69bea44 "Fix for BUG#4017"libmysql/libmysql.c에 대한 실제 diff, gh api repos/mysql/mysql-server/commits/69bea44bc905로 직접 확인. (검증됨, 1차 자료 — 업스트림 저장소의 BitKeeper 이력을 그대로 가져온 Git 미러)
  • 수정 직전 libmysql/libmysql.c 전문 (커밋 2e04da0, 수정 커밋의 부모)mysql_real_connect() 전체 함수 본문, 취약한 memcpy 라인과 주변 지역 변수 선언 순서 확인. (검증됨, 1차 자료)
  • mysys/my_gethostbyname.c (같은 커밋)my_gethostbyname_r() 래퍼가 플랫폼별 gethostbyname_r 호출 규약(glibc2 스타일 / hostent_data 스타일 / 뮤텍스로 감싼 non-reentrant gethostbyname)을 흡수하는 실제 구현. (검증됨, 1차 자료)

미해결/불확실 지점

  • h_length가 DNS 응답의 어느 필드(RDLENGTH 등)에서 어떻게 유래하는지에 대한 구체적 설명은 신고자 코멘트의 주장을 인용한 것이며, 특정 리졸버 라이브러리(glibc 이외)의 소스 코드 레벨로 직접 검증하지는 못했다. glibc/OpenBSD에서는 개발자 본인들도 재현에 실패했다고 명시했으므로, 이 CVE의 CVSS 10.0(공격 범위 Network, 완전한 C/I/A 침해)이 가정하는 "모든 플랫폼에서 원격 코드 실행까지 가능"이라는 최악의 시나리오가 실제로 어느 리졸버 구현·운영체제 조합에서 성립하는지는 확인되지 않았다.
  • lists.mysql.com/internals/14726(NVD가 인용하는 벤더 공지 메일)은 현재 도메인이 일반 포럼 랜딩 페이지로 리다이렉트되어 원문 게시글 내용을 직접 확인할 수 없었다. 이 리포트는 대신 같은 수정 내용이 그대로 담긴 버그 트래커 코멘트와 실제 Git 커밋 diff를 1차 자료로 사용했다.
  • 완전한 공격 체인(공격자가 통제하는 네임서버 응답을 실제로 어떤 바이트열로 구성해야 하는지, 스택 카나리·리턴 주소까지 덮어써 코드 실행에 이르는 구체적 익스플로잇)을 보여주는 공개 자료는 찾지 못했다. 이 리포트는 "검증되지 않은 길이 값을 고정 크기 버퍼로 memcpy한다"는 근본 원인까지만 확정된 사실로 다룬다.