버퍼 오버플로
고친 사람 github-actions[bot]
버퍼 오버플로는 정해진 칸수만큼만 쓰기로 한 공간에 그보다 많은 데이터를 밀어 넣어 옆 공간까지 덮어쓰는 고장입니다. 옆 공간에는 다른 변수와 프로그램이 돌아갈 주소가 놓여 있습니다. 그 값들이 밖에서 온 데이터로 바뀌면서 프로그램이 엉뚱하게 움직입니다.
쉽고 빠른 이해
무슨 고장인가 — 여덟 칸만 쓰기로 한 공간에 스물네 칸을 쓰면 남은 열여섯 칸이 옆 공간을 덮습니다. 옆에 있던 값은 소리 없이 바뀝니다.
왜 따로 이름이 붙었나 — 덮인 곳이 프로그램이 돌아갈 주소면 남이 넣은 데이터가 다음에 실행될 코드를 고르게 됩니다. 값 하나가 틀리는 데서 그치지 않고 프로그램의 조종간이 넘어갑니다.
어떻게 도나
- 바깥에서 받은 데이터를 길이를 재지 않고 버퍼에 복사합니다
- 복사한 양이 버퍼 칸수를 넘어 옆 메모리를 덮습니다
- 덮인 값을 프로그램이 나중에 읽어 쓰면서 엉뚱하게 움직입니다
어디서 나나 — 목적지 칸수를 안 보는 C 같은 언어에서 납니다. Java 나 Rust 처럼 칸을 넘으면 그 줄에서 멈추는 언어에서는 안 납니다.
무엇이 나빠지나 — 터진 곳과 드러나는 곳이 떨어져 있습니다. 넘치게 쓴 함수는 멀쩡히 끝납니다. 한참 뒤 다른 함수가 덮인 값을 읽을 때 멈춥니다. 멈춘 곳만 봐서는 원인을 못 찾습니다.
상세
버퍼는 데이터를 잠깐 담아 두려고 미리 잡아 둔 메모리 구간입니다. 크기를 먼저 정하고 잡기 때문에 몇 칸까지 쓸 수 있는지가 처음부터 정해져 있습니다. 버퍼 오버플로는 그 정해진 칸을 넘겨 쓰는 고장입니다.
넘겨 쓰는 일이 가능한 까닭은 막아서는 검사가 없기 때문입니다. C처럼 메모리를 직접 다루는 언어는 복사할 때 목적지가 몇 칸인지 보지 않습니다. 몇 칸을 쓰라고 시키면 시킨 만큼 씁니다.
버퍼가 끝나는 곳에는 아무 표시도 없습니다. 아홉째 칸에 쓰는 명령과 여덟째 칸에 쓰는 명령은 생김새가 같습니다. 언어가 보기에는 둘 다 메모리 어딘가에 값을 넣으라는 같은 지시입니다.
버퍼가 가득 차는 일과는 다르다
이름이 비슷해서 헷갈리는 일이 하나 있습니다. 처리 속도보다 데이터가 빨리 쌓여 버퍼에 빈 칸이 없어지는 경우입니다. 그때는 새 데이터를 버리거나 백프레셔로 보내는 쪽을 멈춥니다.
그것은 정해진 칸 안에서 벌어지는 일이라 옆 메모리를 건드리지 않습니다. 이 편이 다루는 버퍼 오버플로는 칸 밖으로 나가서 남의 값을 덮는 쪽입니다.
버퍼 옆에는 무엇이 놓이나
넘쳐도 별일 없을 것처럼 들립니다. 이 고장이 심각하게 다뤄지는 까닭은 버퍼 옆에 놓인 것 때문입니다.
함수를 한 번 부를 때마다 스택에는 그 호출이 쓸 구간이 하나 얹힙니다. 이것을 스택 프레임이라고 부릅니다. 함수 안에서 선언한 배열도 이 안에 들어갑니다.
프레임에 지역 변수만 있는 것은 아닙니다. 함수가 끝난 뒤 되돌아갈 코드의 주소도 같은 프레임에 적혀 있습니다. 함수는 끝날 때 그 값을 읽어 거기로 뜁니다.
여덟 칸짜리 문자 버퍼를 가진 함수의 스택 프레임은 이렇게 쌓입니다. 그림에서 위가 높은 주소입니다.
block-beta columns 1 h["↑ 높은 주소"] a["돌아갈 주소"] b["지역 변수 · 예를 들면 개수"] c["문자 버퍼 · 여덟 칸"] l["↓ 낮은 주소"]
스택은 높은 주소에서 낮은 주소로 자랍니다. 그래서 나중에 잡힌 버퍼가 그림의 아래에 놓입니다. 돌아갈 주소는 그보다 위에 있습니다.
복사는 반대쪽으로 갑니다. 버퍼의 첫 칸에서 시작해 위로 올라가며 채웁니다. 칸을 넘긴 데이터는 지역 변수를 지나 돌아갈 주소까지 차례로 덮습니다.
같은 프레임에 스물네 칸을 복사하면 이렇게 됩니다.
block-beta columns 1 h["↑ 높은 주소"] a["돌아갈 주소 · 덮였다"] b["지역 변수 · 덮였다"] c["문자 버퍼 여덟 칸 · 가득 찼다"] l["↓ 낮은 주소"]
여덟 칸이 차고 남은 열여섯 칸이 위로 넘어갑니다. 그 열여섯 칸이 지역 변수와 돌아갈 주소를 차례로 덮은 모습입니다.
터지는 조건
넷이 다 서면 재현됩니다.
- 크기가 정해진 버퍼에 바깥에서 온 데이터를 복사합니다
- 복사하기 전에 데이터의 길이와 버퍼의 칸수를 맞대 보지 않습니다
- 온 데이터가 버퍼의 칸수보다 깁니다
- 언어나 실행 환경이 칸을 넘는 쓰기를 막지 않습니다
하나만 빠져도 이 고장은 안 납니다. 둘째가 빠지면 긴 입력이 복사 전에 걸러집니다. 셋째가 빠지면 넘칠 데이터 자체가 없습니다.
넷째가 언어를 가르는 조건입니다. Java나 Rust처럼 경계 검사를 하는 언어는 범위를 벗어난 접근을 실행 도중에 붙잡아 그 줄에서 멈춥니다. 남의 메모리를 덮는 대신 인덱스 범위 초과 오류가 납니다.
코드로 보는 최소 재현
C 의 문자열은 마지막에 끝을 알리는 값 하나가 붙습니다. strcpy 는 그 값이 나올 때까지 원본을 목적지에 옮기는 함수입니다. 목적지가 몇 칸인지는 인자로 받지 않습니다.
char buf[8]; // 칸 여덟
strcpy(buf, argv[1]); // 들어온 만큼 다 쓴다
argv[1] 은 명령줄로 들어온 문자열입니다. 쓰는 사람이 스물세 글자를 넣으면 끝 표시까지 스물네 칸이 복사됩니다. 그중 열여섯 칸이 버퍼 밖으로 나갑니다. 넣는 글자를 더 늘리면 더 멀리까지 덮입니다.
고치는 길은 복사하는 쪽에 목적지의 칸수를 알려 주는 것입니다.
char buf[8];
strncpy(buf, argv[1], 7); // 일곱까지만
buf[7] = '\0'; // 끝을 닫는다
strncpy 는 셋째 인자로 받은 칸수를 넘지 않습니다. 대신 원본이 길면 끝 표시를 안 붙이므로 마지막 칸을 직접 닫아 줍니다.
덮어쓴 뒤에 갈리는 길
같은 코드가 어떤 입력에서는 멀쩡하고 어떤 입력에서는 프로그램을 날립니다. 무엇이 덮였느냐가 그 차이를 만듭니다.
flowchart TD
A["버퍼 밖으로 데이터가 넘친다"] --> B{"덮인 곳이 무엇인가"}
B -->|안 쓰이는 구간| C["아무 일도 안 일어난다"]
B -->|다른 지역 변수| D["그 값이 소리 없이 바뀐다"]
B -->|돌아갈 주소| E["엉뚱한 곳으로 돌아간다"]
E --> F["프로그램이 끝난다"]
E --> G["넣은 사람이 고른 코드가 돈다"]
갈래는 셋입니다. 그림의 끝이 다섯인 것은 돌아갈 주소가 덮이는 갈래가 한 번 더 갈리기 때문입니다.
안 쓰이는 구간이 덮이는 갈래가 이 고장을 찾기 어렵게 만듭니다. 덮은 곳이 마침 안 쓰이는 구간이면 프로그램은 평소처럼 끝납니다. 잘 도는 것처럼 보이는 동안에도 칸 밖으로 나가는 쓰기는 계속됩니다.
다른 지역 변수가 덮이면 값 하나가 바뀝니다. 개수를 담은 변수가 덮이면 뒤이은 계산이 틀린 답을 냅니다. 프로그램은 멈추지 않으므로 결과를 의심하기 전에는 아무도 모릅니다.
돌아갈 주소가 덮이는 갈래가 보안 사고로 이어집니다. 그 주소가 밖에서 온 값으로 바뀌면 함수가 끝나는 순간 거기로 뜁니다. 대개는 쓸 수 없는 주소입니다. 운영체제가 그 접근을 막아 세그멘테이션 폴트로 프로그램이 끝납니다.
주소를 아무렇게나 덮지 않고 골라서 덮으면 이야기가 달라집니다. 넣은 사람이 함께 밀어 넣은 짧은 코드로 뛰게 만들 수 있습니다. 그런 코드를 셸코드라고 부릅니다.
그러면 그 프로그램의 권한으로 남의 명령이 돕니다. 프로그램이 높은 권한으로 떠 있었다면 권한 상승이 됩니다.
세 갈래 중 어디로 갈지는 언어가 약속하지 않습니다. 칸을 넘긴 쓰기는 정의되지 않은 동작이라 결과가 정해져 있지 않습니다.
스택에서 나는 것과 힙에서 나는 것
넘치는 버퍼가 어디에 잡혀 있었느냐에 따라 덮이는 대상이 달라집니다. 힙은 프로그램이 도는 중에 필요한 만큼 빌려 쓰는 메모리 구역입니다. 빌려준 덩이마다 크기와 다음 덩이를 가리키는 정보가 덩이 옆에 함께 적혀 있습니다.
그 정보는 덩이의 앞머리에 붙습니다. 이것을 머리말이라고 부릅니다. 빌린 데이터는 머리말 바로 뒤에 놓입니다.
block-beta columns 1 a["머리말 · 크기와 다음 덩이"] b["데이터 · 넘치는 버퍼"] c["머리말 · 크기와 다음 덩이"] d["데이터"]
앞 덩이의 데이터가 칸을 넘으면 바로 뒤에 놓인 다음 덩이의 머리말을 덮습니다. 크기와 연결 정보가 밖에서 온 값으로 바뀌는 것입니다.
| 넘친 곳 | 옆에 놓인 것 | 덮이면 |
|---|---|---|
| 스택 | 지역 변수 · 돌아갈 주소 | 함수가 끝나는 순간 다른 곳으로 뛴다 |
| 힙 | 덩이의 크기와 연결 정보 | 메모리를 내놓거나 다시 빌릴 때 관리 구조가 깨진다 |
둘 다 남의 값을 덮습니다. 다른 것은 무엇을 덮느냐뿐입니다. 힙에서 난 것은 덮은 순간이 아니라 그 덩이를 반납하는 때에 드러나는 일이 많습니다. 그래서 원인을 되짚기가 더 까다롭습니다.
막는 층
한 겹으로 끝나지 않고 여러 겹을 겹쳐 씁니다.
| 막는 것 | 무엇을 하나 |
|---|---|
| 길이를 함께 받는 복사 함수 | 목적지 칸수를 넘으면 더 안 쓴다 |
| 경계 검사를 하는 언어 | 범위를 벗어난 접근을 실행 도중에 붙잡아 멈춘다 |
| 스택 카나리 | 버퍼와 돌아갈 주소 사이에 표식 값을 두고 함수가 끝날 때 바뀌었는지 본다 |
| 데이터 실행 방지 | 데이터가 놓인 구간을 실행할 수 없게 표시한다 |
| 주소 공간 배치 난수화 | 실행할 때마다 구간의 주소를 바꿔 목표 주소를 못 찍게 한다 |
| 퍼징 · 정적 분석 | 내보내기 전에 넘치는 입력과 검사 없는 복사를 찾아낸다 |
위 둘은 넘치는 것 자체를 막습니다. 가운데 셋은 넘친 뒤에 조종간이 넘어가는 것을 어렵게 만듭니다. 마지막 줄은 내보내기 전에 미리 찾아내는 것이라 프로그램이 도는 중에는 관여하지 않습니다.
가운데 셋이 어디서 끊는지는 사슬로 보면 드러납니다. 아래 그림의 화살표에 그 마디를 끊는 방어를 적었습니다.
flowchart TD
A["복사한다"] -->|길이를 함께 받는 복사 함수 · 경계 검사를 하는 언어| B["칸을 넘어 쓴다"]
B -->|주소 공간 배치 난수화| C["돌아갈 주소가 골라 둔 값으로 덮인다"]
C -->|스택 카나리| D["함수가 끝나며 그 주소로 뛴다"]
D -->|데이터 실행 방지| E["넣은 코드가 돈다"]
마디가 서로 다르다는 것이 겹쳐 쓰는 까닭입니다. 하나를 우회하는 방법이 알려져도 나머지 마디가 남습니다.
관련 항목
버퍼 오버플로가 일어나는 메모리 구역
메모리 · 스택 · 힙 · 스택 프레임 · 버퍼 · 배열 · 포인터 · 가상 메모리
같은 뿌리에서 나는 메모리 안전 고장
스택 오버플로 · off-by-one · 정수 오버플로 · 해제 후 사용 · 이중 해제 · 댕글링 포인터 · 널 포인터 역참조 · 메모리 누수 · 세그멘테이션 폴트 · 인덱스 범위 초과 · 정의되지 않은 동작
버퍼 오버플로가 번지면 이어지는 보안 사고
권한 상승 · 임의 코드 실행 · 원격 코드 실행 · 셸코드 · 코드 주입 · 반환 지향 프로그래밍 · 스택 스매싱
덮어쓰기를 막거나 어렵게 만드는 방어 장치
스택 카나리 · 주소 공간 배치 난수화 · 데이터 실행 방지 · 경계 검사 · 가드 페이지 · 메모리 안전
내보내기 전에 이 고장을 찾아내는 검사 수단
정적 분석 · 동적 분석 · 퍼징 · 코드 리뷰 · 테스트
이 고장이 나는지를 가르는 프로그래밍 언어
C · C++ · Rust · Java · Go · 가비지 컬렉션
이 고장이 기록되고 등급이 매겨지는 취약점 체계
다른 이름: buffer overflow · 버퍼 오버플로우 · 버퍼 넘침