카테고리:

7 분 소요

문제 상황

RHEL 7에서 사용하던 C++ 프로그램을 RHEL 8로 이전한 뒤 메모리를 free()하는 도중 내부에서 호출된 malloc_consolidate()가 아래와 같은 오류를 검출하고 프로그램을 종료시켰다.

corrupted size vs. prev_size

이 오류는 glibc가 힙 청크의 크기 정보 불일치를 발견했을 때 발생하는 오류다.1 glibc는 일부 작은 청크를 해제할 때 fastbin이라는 목록에 넣고 이웃 청크와의 병합을 미룬다. 이후 free()에서 해제·병합한 청크의 크기가 일정 기준 이상이고 fastbin에 정리할 청크가 있으면, malloc_consolidate()를 호출해 주변의 빈 청크와 합친다.2

같은 arena(힙 관리 단위)의 fastbin 목록을 순회하므로, 방금 free()한 객체와 다른 청크도 처리한다. 앞선 범위 초과 쓰기로 크기 정보가 손상됐더라도 해당 청크를 정리할 때까지 문제가 드러나지 않을 수 있다. 따라서 오류가 보고된 free() 호출 위치와 실제 메모리를 손상시킨 쓰기 위치가 달라질 수 있다.

오류가 발생한 청크와 주변 필드에 watchpoint를 걸어 값이 바뀌는 순간을 추적했다.

문제 해결

1. 오류 청크의 주소 확인

호출 스택에서 오류를 검사하던 프레임을 선택했다. 당시 빌드에서는 malloc_consolidate 프레임의 r14가 검사 대상 청크의 헤더 주소였다.

bt
frame 5
info registers r14 r15
set $victim = (char*)$r14
x/6gx $victim

프레임 번호와 레지스터는 당시 실행 기준이다. 다른 빌드에서는 검사 대상 청크가 어디에 있는지 확인해서 $victim을 지정해야 한다.

여기서 $victim은 malloc()이 반환한 데이터 포인터가 아니라 청크 헤더의 시작 주소다. RHEL 8 x86-64의 일반적인 glibc 청크에서는 데이터 포인터가 헤더보다 0x10바이트 뒤에 있다. 따라서 데이터 포인터에서 -8로 확인하는 size 필드는, 헤더 주소인 $victim에서는 +8 위치다.2

2. 오프셋을 계산해 watchpoint 설정

청크의 size에는 상태 비트가 포함된다. 다음과 같이 하위 상태 비트를 제외한 크기를 확인했다.2

set $vsize = (*(unsigned long*)($victim + 8)) & ~7UL
p/x $vsize

청크 크기가 0x20일 때 주변 필드의 위치는 다음과 같다.

주소 확인 대상
$victim + 8 현재 청크의 size
$victim + 0x20 다음 청크의 prev_size
$victim + 0x28 다음 청크의 size

+0x20과 +0x28은 청크 크기에 따라 달라진다. 일반적으로는 $victim + $vsize와 $victim + $vsize + 8이다. 이미 size가 손상됐다면 계산 결과도 잘못되므로 정상 상태의 청크 크기를 확인해야 한다.2

이미 손상된 뒤에 watchpoint를 걸어도 과거의 쓰기는 찾을 수 없다. 실행을 다시 시작하고, 해당 메모리가 할당된 직후이면서 손상 전인 지점에서 $victim을 현재 실행의 청크 주소로 다시 지정한다.

당시 감시한 위치에 다음과 같이 watchpoint를 설정했다.

watch -l *(unsigned long*)($victim + 8)
watch -l *(unsigned long*)($victim + 0x20)
watch -l *(unsigned long*)($victim + 0x28)
continue

$victim을 char*로 잡아 오프셋을 바이트 단위로 계산하고, 마지막에 unsigned long*로 변환해 각 위치의 8바이트 값을 감시했다. -l은 표현식이 가리키는 메모리 위치를 감시하며, 감시 크기는 역참조한 자료형에 따라 결정된다.3

실제 로그에서는 다음 값의 변화를 확인했다.

Watchpoint 변경 전 변경 후
2 19216492247731712 (0x44454c42414e00) 24442640 (0x174f710)
3 49 (0x31) 24259488 (0x1722ba0)

메모리가 바뀌면서 초기화 함수인 Example::initialize(char*) + 114에서 멈췄다. 이어서 호출 경로와 해당 소스를 확인했다.

bt
frame 0
list

0x31이 포인터 형태의 값인 0x1722ba0으로 바뀐 것이 단서였다. 멈춘 코드가 어디에 값을 저장하는지, 그 주소가 배열의 할당 범위를 벗어나는지 확인하며 쓰기 위치를 좁혔다.

다만 prev_size는 앞 청크가 비어 있을 때 유효한 관리 정보이며, 정상적인 할당·해제 중에도 주변 값이 바뀐다. 값의 변화만으로 오류를 확정하지 않고 호출 스택과 쓰기 대상의 범위를 함께 확인해야 한다.2

이런 문제는 ASan을 적용할 수 있다면 먼저 활용하는 편이 더 수월하다. 버퍼 범위 초과 같은 잘못된 접근을 실행 중에 검출하고 소스 위치와 호출 경로를 보여주므로, 손상된 청크를 찾은 뒤 watchpoint로 쓰기 지점을 추적하는 수고를 줄일 수 있다.4

참고 자료

태그: C++, GDB, glibc, RHEL, RHEL8, watchpoint, 메모리 오류, 힙 손상

업데이트:

정보의 바다를 헤엄치는 수달의 마음으로

syudal 이미지 프로필