스택의 유령 (1부): 초기화되지 않은 변수가 이전 데이터를 기억하는 이유
출처: Dev.to
C 프로그램을 작성하고 실행했을 때, 직접 할당하지 않은 값을 출력하는 것을 본 적이 있나요?
스택 프레임 재사용
컴파일러 동작
메모리 영속성
그리고 현대 시스템에 내장된 성능 트레이드오프
이 시리즈에서는 고수준 C 코드에서 스택 프레임, 어셈블리 명령어, 그리고 궁극적으로 실제 보안 이슈까지 살펴볼 것입니다.
#include <stdio.h>
void Subtraction()
{
int a = 100;
int b = 35;
int c = a - b;
}
void PrintValues()
{
int i, j, k;
printf("First value is %d\nSecond value is %d\nThird value is %d\nThese are the values for PrintValues function\n", i, j, k);
}
int main()
{
Subtraction();
PrintValues();
return 0;
}
최적화 없이 컴파일하면(gcc -O0 main.c) 다음과 같은 출력이 나타날 수 있습니다.
First value is 100
Second value is 35
Third value is 65
These are the values for PrintValues function
처음 보면 불가능해 보입니다. PrintValues는 변수를 전혀 초기화하지 않는데, 왜 바로 직전에 Subtraction에서 사용한 동일한 값들을 출력할까요?
중요 경고: 이것은 정의되지 않은 동작(undefined behaviour)이며, C 표준은 어떤 일이 일어날지 보장하지 않습니다.
- 다른 컴파일러는 서로 다르게 동작할 수 있습니다.
- 최적화 레벨에 따라 결과가 달라질 수 있습니다.
- 앞으로 실행할 때마다 다른 출력이 나올 수도 있습니다.
더러운 화이트보드 비유
무슨 일이 일어나고 있는지 이해하려면 코드를 잠시 제쳐두세요. RAM을 공유 교실의 화이트보드에 비유해 봅시다.
첫 번째 교사(Subtraction())가 교실에 들어와서 화이트보드에 100, 35, 65를 적습니다. 수업이 끝난 뒤, 지우지 않은 채 떠나는데, 지우는 데 시간이 걸리기 때문입니다. 잠시 방이 비어 있어도 보드에 적힌 내용은 그대로 남아 있습니다.
두 번째 교사(PrintValues())가 바로 그 뒤에 교실에 들어옵니다. 새로운 내용을 적지 않고, 이미 보드에 남아 있는 것을 그대로 읽어옵니다.
고수준에서 보면, 이것이 스택 메모리에서 일어나는 일과 유사합니다. 함수가 반환될 때, 일반적으로 컴파일된 코드는 로컬 변수에 사용된 스택 바이트를 자동으로 지우지 않습니다. 대신
- 스택 공간이 재사용 가능해집니다.
- 오래된 데이터가 일시적으로 남아 있습니다.
- 다른 함수가 같은 스택 오프셋을 재사용할 수 있습니다.
SUBTRACTION() 함수의 어셈블리 살펴보기
아래는 x86‑64 환경에서 GDB가 출력한 Subtraction() 함수의 디스어셈블리입니다.
push %rbp
mov %rsp,%rbp
sub $0x30,%rsp
movl $0x64,-0x4(%rbp)
movl $0x23,-0x8(%rbp)
mov -0x4(%rbp),%eax
sub -0x8(%rbp),%eax
mov %eax,-0xc(%rbp)
mov -0xc(%rbp),%ecx
mov -0x8(%rbp),%edx
mov -0x4(%rbp),%eax
add $0x30,%rsp
pop %rbp
ret
| 어셈블리 | 설명 |
|---|---|
push %rbp | 베이스 포인터를 스택에 저장 |
mov %rsp,%rbp | 스택 포인터를 베이스 포인터로 설정 |
sub $0x30,%rsp | 스택에 48바이트 할당 |
movl $0x64,-0x4(%rbp) | 100을 스택의 첫 4바이트에 저장 |
movl $0x23,-0x8(%rbp) | 35를 두 번째 슬롯에 저장 |
mov -0x4(%rbp),%eax | 100을 eax 레지스터에 로드 |
sub -0x8(%rbp),%eax | 35를 빼고 결과를 eax에 남김 |
mov %eax,-0xc(%rbp) | 결과를 메모리(스택)로 이동 |
mov -0xc(%rbp),%ecx | 레지스터로 이동 |
mov -0x8(%rbp),%edx | 레지스터로 이동 |
mov -0x4(%rbp),%eax | 레지스터로 이동 |
add $0x30,%rsp | 할당했던 스택 공간 정리 |
pop %rbp | 이전 스택 프레임 복원 |
ret | 호출자에게 제어 반환 |
PrintValues() 함수의 시작 어셈블리
다음은 PrintValues() 함수가 생성한 초기 어셈블리입니다.
push %rbp
mov %rsp,%rbp
sub $0x30,%rsp
mov -0xc(%rbp),%eax
mov -0x4(%rbp),%edx
mov -0x8(%rbp),%ecx
여기서 눈에 띄는 점은 PrintValues()도 거의 동일한 스택 레이아웃을 할당한다는 것입니다. 따라서
mov -0x4(%rbp),%eax
와 같은 명령은 “여기에 현재 존재하는 바이트를 읽어라”는 의미입니다. 만약 그 바이트가 아직 Subtraction()에서 남은 데이터라면, PrintValues()는 이전 값을 “기억”하고 있는 것처럼 보일 수 있습니다.
이 현상은 초기화되지 않은 변수가 위험하다는 사실을 넘어, 메모리는 자동으로 청소되지 않고 재사용된다는 시스템 원칙을 보여줍니다.