헤맨 만큼 내 땅이다
02 Stack Buffer Overflow (off-by-one) 본문
문제 상황
파스칼의 삼각형을 1차원 배열에 삼각 인덱싱으로 채우는 코드다. 실행하면 정상적으로 14개 행을 다 출력한 뒤, 마지막에 갑자기 다음 에러와 함께 종료된다.
*** stack smashing detected ***: terminated
출력은 끝까지 정상인데 프로그램만 비정상 종료되는 것이 특징이다.
원인 코드
static void build_pascal(int *tri, int rows) {
for (int i = 0; i <= rows; i++) { // 문제 지점
for (int j = 0; j <= i; j++) {
...
}
}
}
ROWS가 14일 때, 유효한 행은 0번부터 13번까지 총 14개다. 그런데 i <= rows 조건 때문에 i가 0부터 14까지, 즉 15번 돈다. 존재하지 않는 15번째 행(i = 14)까지 만들려고 하는 것이 근본 원인이다.
배열 밖을 넘어 쓰는 지점
인덱스 계산 함수는 다음과 같다.
static int tri_index(int i, int j) {
return i * (i + 1) / 2 + j;
}
i = 14, j = 0을 대입하면 idx = 14 * 15 / 2 = 105가 나온다. 배열은 enum { SIZE = ROWS * (ROWS + 1) / 2 }, 즉 SIZE = 105로 선언되어 있다. 유효한 인덱스는 0 ~ 104뿐이므로, tri[105]는 배열 밖의 메모리를 건드리는 것이다.
왜 하필 return 0;에서 죽는가
오버플로가 일어나는 시점(105번 인덱스에 쓰는 순간)과 에러가 발생하는 시점(main의 return 직전)이 서로 다르다는 것이 이 문제의 핵심이다.
- 컴파일러는 지역 배열 뒤에 스택 카나리(canary) 라는 감시용 값을 몰래 심어둔다.
- tri[105]에 쓰는 순간 그 카나리 값이 조용히 덮어써진다. 이때는 아무 에러도 나지 않는다.
- build_pascal과 이후 출력문들은 모두 정상적으로 끝까지 실행된다.
- main이 함수를 끝내고 돌아가기(return) 직전, 컴파일러가 심어둔 코드가 "카나리 값이 그대로인지" 검사한다.
- 값이 바뀌어 있으므로 침입을 감지하고 abort를 호출해 프로그램을 강제 종료시킨다.
즉, 범행(배열 밖 쓰기)은 함수 실행 중 조용히 일어나고, 경보(스택 스매싱 감지)는 함수가 끝나는 시점에야 울리는 것이다.
gdb로 직접 확인하는 과정
1단계 — gdb 실행
make gdb NAME=02_stack_buffer_overflow
2단계 — run으로 실제 크래시 재현
(gdb) run
row 0: 1 (sum=1)
...
row 13: 1 13 78 286 ... 13 1 (sum=8192)
SIZE = 105
*** stack smashing detected ***: terminated
Program received signal SIGABRT, Aborted.
0x0000fffff7e774d8 in ?? () from /lib/aarch64-linux-gnu/libc.so.6
14개 행과 SIZE까지는 전부 정상 출력된 뒤에 크래시가 난 것을 확인할 수 있다. 즉 로직상 계산 자체는 문제없이 끝났고, 뭔가 "뒷정리" 단계에서 문제가 생겼다는 뜻이다.
3단계 — bt(backtrace)로 호출 스택 확인
(gdb) bt
#0 0x0000fffff7e774d8 in ?? () from libc.so.6
#1 0x0000fffff7e2cb3c in raise () from libc.so.6
#2 0x0000fffff7e17e00 in abort () from libc.so.6
#3 0x0000fffff7e6aac4 in ?? () from libc.so.6
#4 0x0000fffff7ee82a8 in __fortify_fail () from libc.so.6
#5 0x0000fffff7ee9264 in __stack_chk_fail () from libc.so.6
#6 0x0000aaaaaaaa0b58 in main () at bug.c:112
bt는 번호가 낮을수록 가장 최근에 실행된(안쪽) 함수, 높을수록 그걸 호출한(바깥쪽) 함수를 의미한다. 그래서 아래에서 위로 읽으면 사건의 흐름이 보인다.
#6 main () at bug.c:112 ← 우리 코드. return 하려던 참이었다
#5 __stack_chk_fail () ← "카나리 값이 깨졌다!"를 감지한 함수
#4 __fortify_fail () ← "그럼 강제 종료 처리한다"
#3~#0 abort, raise 등 ← 실제로 프로세스를 죽이는 내부 처리
#0~#3이 함수 이름 없이 ??로 나오는 것은 그 부분이 glibc의 디버그 심볼이 없는 내부 구현이기 때문이며, 우리 코드와는 무관한 부분이다. 신경 쓸 필요가 있는 건 #5, #6뿐이다.
4단계 — frame과 list로 정확한 소스 위치 확인
#6이 정확히 어느 줄인지 직접 소스로 확인하기 위해 해당 프레임으로 이동한다.
(gdb) frame 6
(gdb) list
return 0;
} // 112라인
main()이 return 0; 문 자체(112라인)에서 멈춰 있는 것을 직접 확인했다. 이걸로 앞서 추론한 "오버플로는 조용히 일어나고, 검사는 return 직전에 이뤄진다"는 가설이 실제 gdb 출력으로 증명된 셈이다.
수정
for (int i = 0; i < rows; i++) { // <= 를 < 로
유효한 행 번호는 0 ~ rows-1이므로 <로 바꿔주면 정확히 필요한 개수만큼만 반복된다.
수정 후 재확인
make run NAME=02_stack_buffer_overflow
stack smashing 에러 없이 14개 행이 모두 출력되고, SIZE = 105가 찍힌 뒤 정상 종료(exit code 0)되는 것을 확인했다.
배운 점
- 인덱싱 계산식이 복잡할수록 "마지막으로 접근하는 인덱스"를 직접 손으로 계산해서 배열 크기와 비교해보는 습관이 필요하다.
- 스택 버퍼 오버플로는 오버플로가 발생하는 지점과 크래시가 감지되는 지점이 다를 수 있다. bt로 크래시 지점만 보고 끝내면 안 되고, frame/list로 실제 소스 위치까지 내려가서 확인해야 한다.
- bt 결과에서 ??로 나오는 프레임은 라이브러리 내부 구현이라 무시해도 되고, 내 코드가 보이는 프레임(main () at bug.c:112)에 집중하면 된다.
'week5' 카테고리의 다른 글
| 트러블슈팅2 (0) | 2026.09.30 |
|---|---|
| 퀴즈 5번 : 어셈블리 → C 코드 복원 (0) | 2026.09.22 |