[RHEL] ASan으로 corrupted size vs. prev_size 오류 해결하기
카테고리: Linux(RHEL, Ubuntu)
문제 상황
RHEL 7에서 사용하던 C++ 프로그램을 RHEL 8로 이전한 뒤 free()를 실행하면서 아래와 같은 오류로 종료되는 문제가 발생했다.
corrupted size vs. prev_size
glibc가 힙 청크의 크기 정보 불일치를 발견했을 때 발생하는 오류다.1 오류가 발견되는 해제 위치와 실제 메모리를 손상시킨 쓰기 위치는 다를 수 있어, ASan으로 잘못된 접근을 추적했다.
문제 해결
1. ASan 설치와 컴파일 설정
ASan(AddressSanitizer)은 C/C++ 프로그램의 메모리 오류를 실행 중에 검출하는 도구다. 컴파일 시 검사 코드를 삽입하고 런타임 라이브러리와 함께 동작해, 버퍼 범위를 벗어난 접근이나 해제된 메모리 사용 등을 오류 위치와 호출 경로로 알려준다.2
RHEL 8의 기본 GCC 환경에서는 필요한 런타임 라이브러리인 libasan을 설치한다.3
yum install libasan
검사할 소스의 컴파일과 최종 실행 파일의 링크에 ASan 옵션을 함께 적용한다. 공유 라이브러리 libexample.so 안의 직접적인 메모리 접근도 검사하려면 해당 소스도 ASan으로 빌드해야 한다.2
CXXFLAGS += -g -O1 -fsanitize=address -fno-omit-frame-pointer
LDFLAGS += -fsanitize=address
-O1은 필수 조건은 아니며 -O0이나 -Og도 사용할 수 있다.
2. errno로 mmap 실패 원인 확인
ASan을 적용한 뒤 MMF를 연결하는 mmap()에서 먼저 실패했다. 반환값이 MAP_FAILED일 때 errno를 바로 저장해 확인했다. 다른 함수 호출로 오류 정보가 바뀌는 것을 피하기 위해서다.4
#include <sys/mman.h>
#include <errno.h>
#include <stdio.h>
#include <string.h>
// mmap()의 반환값을 mapped에 저장한 직후 확인한다.
if (mapped == MAP_FAILED) {
const int saved_errno = errno;
fprintf(stderr, "fd=%d, errno=%d (%s)\n",
file_fd, saved_errno, strerror(saved_errno));
}
실제 출력은 다음과 같았다.
fd=3, errno=12 (Cannot allocate memory)
당시 매핑 크기는 174,476,720바이트, 약 166.394 MiB였다.
errno=12는 ENOMEM이다. 이런 상항에서는 물리 메모리 부족 외에도 가상 주소 공간, 프로세스 자원 제한, 매핑 개수 제한 등을 확인해야 한다.5
3. setrlimit 설정 수정
실행 중인 프로세스의 제한과 메모리 상태를 확인했다.
target_pid=12345
cat /proc/$target_pid/limits
cat /proc/$target_pid/status
wc -l /proc/$target_pid/maps
cat /proc/sys/vm/max_map_count
당시 매핑은 약 352개였고 vm.max_map_count는 16,777,216이어서, 그 시점의 매핑 개수 제한에 도달한 상황으로 보기는 어려웠다.
ASan 실행에서는 VmSize가 약 20 TiB인데 VmRSS는 약 10 MiB인 경우도 관찰했다. ASan이 검사에 사용할 큰 가상 주소 공간을 매핑하기 때문이며, VmSize가 곧 실제 RAM 사용량은 아니다.2
RLIMIT_AS와 RLIMIT_DATA 같은 프로세스 자원 제한도 확인했다. 특히 RLIMIT_AS는 가상 주소 공간의 상한이므로 RAM에 여유가 있어도 mmap()이 실패할 수 있다.6
프로그램 내부의 setrlimit() 설정을 수정한 뒤 이 매핑 실패가 해결됐다.
4. MAP_FIXED와 ShadowGap 충돌 해결
이후에는 매핑에 성공했지만 MMF에 접근하는 과정에서 SIGSEGV가 발생했다. 사용 중인 매핑은 다음과 같은 형태였다.
mmap((void*)requested_addr, mapping_size, PROT_READ | PROT_WRITE,
MAP_FIXED | MAP_PRIVATE, file_fd, 0);
ASan은 별도의 Shadow 메모리로 접근 가능 여부를 검사하고, 기본 주소 배치에서 일부 구간을 ShadowGap으로 보호한다.7 MAP_FIXED는 기존 매핑과 겹쳐도 해당 부분을 제거하고 지정 주소에 강제로 매핑하므로 이 배치와 충돌할 수 있다.5
이번 프로그램에서는 다음 옵션을 적용한 뒤 ShadowGap 관련 MMF 접근 오류가 해결됐다.
ASAN_OPTIONS=protect_shadow_gap=0 ./sample_app
기존 ASAN_OPTIONS가 있다면 필요한 항목을 유지하면서 protect_shadow_gap=0을 추가한다. 환경변수를 적용한 뒤에는 프로세스를 새로 실행해야 하며, 필요하면 verbosity=1로 시작 로그를 확인할 수 있다.
이 옵션은 ShadowGap의 접근 보호를 해제하고 그 영역을 검사할 추가 Shadow도 준비한다.8 ASan의 메모리 접근 검사는 계속 동작한다.
5. ASan으로 찾은 포인터 배열 할당 오류 수정
ASan으로 문제의 쓰기 코드를 확인한 결과, 포인터 개수만큼만 메모리를 할당해 배열에 필요한 공간이 부족했다. 각 문자열 버퍼의 공간과 그 주소를 담는 포인터 배열의 공간은 따로 계산해야 한다.
malloc()의 인자는 요소 개수가 아니라 바이트 수다. 예를 들어 포인터 10개를 담으려면 다음과 같이 할당해야 한다.
#include <stdlib.h>
const size_t count = 10;
// 수정 전: char** args = (char**)malloc(count);
char** args = (char**)malloc(count * sizeof(*args));
64비트 환경에서 포인터가 8바이트라면 10개에 필요한 공간은 80바이트다. 10바이트만 할당한 상태에서 여러 포인터를 저장하면 할당 범위를 넘어 힙을 손상시킬 수 있다.
malloc(10) 자체는 정상적인 10바이트 요청이므로 할당은 성공할 수 있다. 문제는 이후 포인터를 저장하면서 범위를 넘어 주변 힙의 관리 정보를 덮어쓰는 시점에 생긴다. 일반 실행에서는 이런 쓰기가 즉시 검출되지 않을 수 있다.
glibc 2.26 버전 부터는 free() 과정에서 인접한 빈 청크를 병합하거나 목록에서 분리할 때, 한 청크의 size와 다음 청크의 prev_size가 맞는지 검사한다. 앞선 쓰기로 이 정보가 손상돼 있으면 다른 메모리를 해제하다가도 corrupted size vs. prev_size로 종료될 수 있다.1
이때 free() 호출 위치는 손상이 발견된 지점이며, 실제 원인은 앞서 발생한 범위 초과 쓰기였다.
포인터 개수에 sizeof(*args)를 곱하도록 수정한 뒤, 기존에 발생하던 corrupted size vs. prev_size 오류가 재발하지 않았다.
참고 자료
정보의 바다를 헤엄치는 수달의 마음으로