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.c의 mysql_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 문자열이 무엇을 가리키는지 알아내야 한다. 코드는 두 경로로 나뉜다.
host가"192.0.2.10"처럼 이미 점-십진 표기 IP 리터럴이면inet_addr()로 바로 32비트 정수 주소를 얻는다.- 그렇지 않고
"db.example.com"처럼 도메인 이름이면, DNS 조회를 통해 이름을 IP 주소로 바꿔야 한다. 이 경로에서my_gethostbyname_r()이 호출된다.
이 리포트가 다루는 버그는 두 번째 경로, DNS로 해석한 결과를 소켓 주소 구조체에 옮겨 담는 지점에서 발생한다.
유닉스 계열 시스템에서 이름 해석 결과는 전통적으로 <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은 이 플랫폼별 차이를 감추기 위해 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 서버가 말한 값"에서 유래하는 신뢰 경계 바깥의 입력이라는 것이다.
반대로 memcpy의 목적지인 sock_addr.sin_addr는 얘기가 다르다. mysql_real_connect()는 접속용 소켓 주소를 다음처럼 IPv4 전용 구조체로 선언한다.
struct sockaddr_in sock_addr; /* mysql_real_connect() 지역 변수 */struct sockaddr_in의 sin_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바이트로 고정된 영역) 사이에 크기 검증이 없다는 것이 이 버그의 본질이다.
수정 직전 소스(커밋 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 등)를 지나 스택의 다음 지역 변수까지 이어서 덮어쓴다.
정상 케이스 (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 길이로 쓰는 코드)이 다른 곳에는 없음을 확인했다고 코멘트에 남겼다.
공격자가 통제하는 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-reentrantgethostbyname)을 흡수하는 실제 구현. (검증됨, 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한다"는 근본 원인까지만 확정된 사실로 다룬다.