Notice
Recent Posts
Recent Comments
Link
«   2026/11   »
일 월 화 수 목 금 토
1 2 3 4 5 6 7
8 9 10 11 12 13 14
15 16 17 18 19 20 21
22 23 24 25 26 27 28
29 30
Archives
Today
Total
관리 메뉴

헤맨 만큼 내 땅이다

02 Stack Buffer Overflow (off-by-one) 본문

week5

02 Stack Buffer Overflow (off-by-one)

신속다람 2026. 9. 22. 20:50

문제 상황

파스칼의 삼각형을 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 직전)이 서로 다르다는 것이 이 문제의 핵심이다.

  1. 컴파일러는 지역 배열 뒤에 스택 카나리(canary) 라는 감시용 값을 몰래 심어둔다.
  2. tri[105]에 쓰는 순간 그 카나리 값이 조용히 덮어써진다. 이때는 아무 에러도 나지 않는다.
  3. build_pascal과 이후 출력문들은 모두 정상적으로 끝까지 실행된다.
  4. main이 함수를 끝내고 돌아가기(return) 직전, 컴파일러가 심어둔 코드가 "카나리 값이 그대로인지" 검사한다.
  5. 값이 바뀌어 있으므로 침입을 감지하고 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