메모리 안전
고친 사람 github-actions[bot]
메모리 안전은 프로그램이 제 몫이 아닌 메모리를 건드리지 못하게 막는 성질입니다. 배열 끝을 넘어 옆 데이터를 덮어쓰는 일도, 이미 돌려준 메모리에 다시 쓰는 일도 일어나지 않습니다. 자바나 파이썬으로 짠 서버는 언어가 이 성질을 대신 지켜 줍니다. C 와 C++ 에서는 짜는 사람이 직접 지켜야 합니다.
쉽고 빠른 이해
메모리 안전은 코드가 자기에게 허락된 메모리만 읽고 쓰게 하는 성질입니다. 길이가 4인 배열의 다섯째 칸에 쓰려 하면 자바는 예외를 던지고 멈춥니다.
이 성질이 없으면 잘못된 쓰기가 멈추지 않고 옆 데이터를 조용히 망가뜨립니다. 범위를 넘는 읽기는 옆에 있던 남의 데이터를 가져옵니다. 공격자는 이 틈으로 서버 메모리의 비밀을 읽어 가거나 자기 코드를 실행시킵니다.
언어는 대개 세 가지로 지킵니다.
- 배열에 접근할 때마다 번호가 범위 안인지 확인합니다
- 아직 누가 쓰고 있는 메모리는 돌려주지 않습니다
- 새로 잡은 메모리에 값을 채운 뒤에 넘깁니다
대가는 이 확인과 뒷정리에 드는 실행 시간과 메모리입니다. 그래서 운영체제처럼 확인 하나도 아끼려는 코드는 오래 C 와 C++ 로 짜 왔습니다.
상세
학교 복도에 번호 붙은 사물함이 줄지어 있다고 해 봅시다. 내 사물함은 3번부터 6번까지 네 칸입니다. 칸을 하나 더 세면 7번, 옆 반 친구의 사물함이 열립니다. 졸업한 선배가 쓰던 번호를 기억해 두었다가 열면 새로 배정받은 후배의 물건이 나옵니다.
메모리 안전은 프로그램이 메모리를 읽고 쓸 때마다 두 조건이 지켜지는 성질입니다. 첫째는 범위 조건입니다. 접근하는 곳이 대상의 범위 안에 있어야 합니다. 사물함 7번을 연 것이 이 조건을 깬 경우입니다.
둘째는 살아 있음 조건입니다. 그 대상이 아직 살아 있어야 합니다. 선배 번호로 사물함을 연 것이 이 조건을 깬 경우입니다. 살아 있다는 것은 메모리를 잡아 둔 채 아직 돌려주지 않았다는 뜻입니다.
범위 조건은 공간 안전(spatial safety), 살아 있음 조건은 시간 안전(temporal safety)이라고도 합니다. 이 문서는 범위 조건과 살아 있음 조건이라는 이름으로 부릅니다.
이 절은 C 코드 몇 줄을 같은 일을 하는 자바 코드와 견줍니다. 먼저 메모리를 가리키는 방법을 봅니다. 이어서 두 조건이 깨지는 모습을 하나씩 봅니다. 그다음 깨졌을 때 무엇이 위험한지, 언어와 실행 환경이 어떻게 지키는지, 그 대가가 무엇인지를 봅니다.
메모리를 가리키는 값
프로그램이 쓰는 메모리는 번호 붙은 칸이 길게 늘어선 모양입니다. 칸 하나에 1바이트가 들어갑니다. 칸마다 붙은 번호를 주소라고 부릅니다.
주소를 담은 값을 포인터라고 부릅니다. 자바의 참조도 객체를 가리킨다는 점에서 같은 일을 합니다.
차이는 계산을 허락하느냐입니다. C 에서는 포인터에 숫자를 더하고 빼서 아무 칸이나 가리킬 수 있습니다. 자바는 참조로 계산을 못 하게 막아 둡니다.
배열은 같은 크기의 값을 이어 붙인 덩어리입니다. 그 값 하나하나를 원소라고 합니다. a[i] 는 배열의 시작 주소에서 원소 i 개만큼 건너간 곳을 가리킵니다.
그래서 i 가 너무 크면 배열 끝을 지나 그 뒤에 놓인 다른 데이터를 가리키게 됩니다.
범위를 넘는 접근
이 소절은 길이가 4인 배열의 다섯째 원소에 쓰는 코드 한 줄을 C 와 자바로 견줍니다. 범위 조건이 깨지는 모습입니다.
int a[4];
int count = 0;
a[4] = 7; // count 가 7 로 바뀔 수 있다
C 컴파일러는 4가 범위 밖인지 확인하는 코드를 넣지 않습니다. 7은 배열 바로 뒤의 칸에 들어갑니다.
그 칸에 변수 count 가 놓여 있었다면 count 의 값이 0에서 7로 바뀝니다.
아래 그림은 int 하나가 4바이트를 차지하는 경우입니다. 그래서 원소 하나를 건널 때마다 주소가 4씩 늘어납니다.
count 는 배열 바로 뒤에 놓여 있습니다.
block-beta columns 1 a0["주소 100 · a[0]"] a1["주소 104 · a[1]"] a2["주소 108 · a[2]"] a3["주소 112 · a[3]"] c["주소 116 · count · a[4] 도 여기를 가리킨다"]
a[4] 는 시작 주소 100에서 원소 네 개를 건너간 116을 가리킵니다.
변수를 어느 순서로 놓을지는 컴파일러가 정하므로, 덮이는 것이 count 가 아닐 수도 있습니다. 무엇이 덮이든 배열 밖의 데이터라는 점은 같습니다.
데이터를 잠시 담아 두는 메모리 덩어리를 버퍼라고 부릅니다. 버퍼 끝을 넘어 쓰는 이 고장을 버퍼 오버플로라고 부릅니다. 사용자 입력을 길이 확인 없이 버퍼에 복사하면, 입력이 길 때 같은 일이 납니다.
같은 줄을 자바로 쓰면 결과가 다릅니다.
int[] a = new int[4];
a[4] = 7; // ArrayIndexOutOfBoundsException
자바 가상 머신은 배열마다 길이를 함께 들고 있습니다. 원소에 접근할 때마다 인덱스를 길이와 견줘 봅니다. 넘으면 예외를 던집니다. 이 확인을 경계 검사라고 부릅니다. 배열 뒤의 데이터는 손대지 않은 채 남습니다.
이미 돌려준 메모리
이 소절은 메모리를 빌렸다가 돌려준 뒤에 옛 주소로 다시 쓰는 코드를 봅니다. 살아 있음 조건이 깨지는 모습입니다.
크기를 미리 알 수 없는 데이터는 실행 중에 힙이라는 구역에서 메모리를 빌려 씁니다. 빌려주고 돌려받는 일은 메모리 할당자가 맡습니다.
C 에서는 malloc 으로 빌리고 free 로 돌려줍니다. 아래 코드는 16바이트를 빌렸다가 돌려준 뒤 그곳에 다시 씁니다.
char *p = malloc(16); // 16바이트를 빌린다
free(p); // 돌려준다
p[0] = 'x'; // 돌려준 곳에 쓴다
free 를 불러도 p 에 담긴 주소는 지워지지 않습니다. 마지막 줄은 이 옛 주소로 씁니다.
할당자는 돌려받은 곳을 다음 요청에 다시 내줍니다. 그 사이 다른 코드가 같은 곳을 빌려 갔다면 마지막 줄은 그 코드의 데이터를 덮어씁니다.
sequenceDiagram
participant A as 코드 A
participant M as 메모리 할당자
participant B as 코드 B
A->>M: 16바이트 빌려 줘
M-->>A: 주소 200
A->>M: 주소 200 돌려줄게
B->>M: 16바이트 빌려 줘
M-->>B: 주소 200
Note over A,B: A 가 옛 주소 200 에 쓰면 B 의 데이터가 바뀐다
그림은 코드 A 가 주소 200 을 돌려준 뒤 코드 B 가 같은 곳을 받는 순서입니다. A 는 자기가 돌려준 줄 모르고 계속 씁니다. B 는 자기 데이터가 바뀐 줄 모르고 계속 읽습니다.
free 뒤의 p 처럼 돌려준 메모리를 여전히 가리키는 포인터를 댕글링 포인터라고 부릅니다. 그 포인터로 읽거나 쓰는 고장이 해제 후 사용입니다.
같은 메모리를 두 번 돌려주는 고장은 이중 해제라고 합니다. 할당자는 돌려받은 곳을 다음에 내줄 빈 메모리 목록에 올려 둡니다.
두 번 돌려받으면 같은 곳이 목록에 두 번 올라갑니다. 그러면 뒤이은 두 요청에 같은 주소를 내줄 수 있습니다. 두 코드가 한 곳을 나눠 쓰게 되니 해제 후 사용과 같은 일이 납니다.
이름 붙은 고장들
두 조건이 깨지는 모습에는 저마다 이름이 붙어 있습니다. 보안 공지나 크래시 보고서에 나오는 이름들을 한 줄씩 짚습니다.
| 고장 | 깨지는 조건 | 무슨 일이 나나 |
|---|---|---|
| 버퍼 오버플로 | 범위 | 버퍼 끝을 넘어 옆 데이터를 덮어쓴다 |
| 범위 밖 읽기 | 범위 | 버퍼 끝을 넘어 옆 데이터를 읽어 간다 |
| 해제 후 사용 | 살아 있음 | 돌려준 메모리를 옛 주소로 읽고 쓴다 |
| 이중 해제 | 살아 있음 | 같은 메모리를 두 번 돌려준다 |
| 널 포인터 역참조 | 범위 | 아무것도 안 가리키는 포인터로 읽고 쓴다 |
마지막 줄의 널 포인터는 「아무것도 가리키지 않음」을 나타내려고 쓰는 특별한 포인터 값입니다. 가리키는 대상이 없으니 안에 들어갈 범위도 없습니다. 그래서 어디를 읽든 범위 밖으로 보고 범위 조건 쪽에 둡니다.
표에 없는 셋째 조건을 더해 셋으로 보기도 합니다. 값을 넣기 전의 메모리를 읽지 않는다는 초기화 조건입니다. 새로 빌린 메모리에는 앞서 그 칸을 쓰던 코드의 데이터가 남아 있을 수 있습니다. 이것을 읽는 고장을 초기화되지 않은 메모리 읽기라고 부릅니다.
깨지면 벌어지는 일
C 언어는 범위 밖 접근과 해제 후 사용의 결과를 정해 두지 않습니다. 이것을 정의되지 않은 동작이라고 부릅니다. 무슨 일이 나든 언어가 책임지지 않는다는 뜻입니다.
문제는 프로그램이 멈추지 않을 수 있다는 것입니다. 잘못 쓴 값은 다른 변수에 들어앉아 한참 뒤 엉뚱한 곳에서 문제를 냅니다. 운영체제가 막아 둔 주소에 닿았을 때만 세그멘테이션 폴트로 프로세스가 죽습니다.
공격자는 멈추지 않는 이 틈을 노립니다. 함수가 쓰는 메모리는 스택이라는 구역에 쌓입니다. 함수 안에서 잡은 버퍼가 여기 놓입니다. 그 함수가 끝난 뒤 돌아갈 주소도 같은 스택의 버퍼 가까이에 적힙니다.
긴 입력으로 버퍼를 넘치게 하면 넘친 바이트가 이 돌아갈 주소까지 덮습니다. 그러면 함수가 끝날 때 원래 부른 곳이 아니라 공격자가 고른 주소로 뛰어갑니다.
이렇게 남의 기계에서 공격자가 고른 코드를 돌리는 것을 임의 코드 실행이라고 합니다. 서버라면 그 서버 프로세스의 권한으로 무엇이든 할 수 있게 됩니다.
범위 밖 읽기는 반대 방향으로 샙니다. 응답을 만들면서 버퍼 끝을 넘어 읽으면, 옆 칸에 있던 비밀번호나 암호 키가 응답에 실려 나갑니다.
그래서 메모리 안전은 보안 문제로 다뤄집니다. C 와 C++ 로 짠 큰 소프트웨어에서 심각한 취약점으로 보고되는 것 가운데 많은 수가 메모리 안전 고장입니다. 자바에서는 같은 실수가 예외 한 번으로 끝납니다. 요청 하나가 실패할 뿐 옆 데이터는 멀쩡합니다.
언어가 지키는 방법
이 소절은 메모리 안전을 보장하는 언어가 범위 · 살아 있음 · 초기화 세 조건을 무엇으로 지키는지 봅니다.
| 지키는 조건 | 방법 | 쓰는 언어 |
|---|---|---|
| 범위 | 접근할 때마다 경계 검사를 하고 포인터 계산을 막는다 | 자바 · 파이썬 · Go · Rust |
| 살아 있음 | 누가 아직 가리키고 있으면 돌려주지 않는다 | 자바 · 파이썬 · Go |
| 살아 있음 | 누가 언제 돌려주는지를 컴파일할 때 따진다 | Rust |
| 초기화 | 새 메모리를 기본값으로 채우거나, 값을 넣기 전에 읽는 코드를 컴파일 오류로 막는다 | 자바 · Go · Rust |
첫 줄은 앞에서 본 경계 검사입니다. 포인터 계산까지 막아야 하는 까닭은, 계산을 허락하면 배열 길이와 상관없이 아무 주소나 만들 수 있기 때문입니다.
둘째 줄의 방법을 가비지 컬렉션이라고 부릅니다. 프로그래머는 메모리를 돌려주지 않습니다. 언어가 프로그램과 함께 돌리는 런타임이 어떤 참조도 닿지 않는 객체를 찾아 대신 돌려줍니다. 참조가 하나라도 남아 있으면 돌려주지 않으므로 댕글링 포인터가 생길 수 없습니다.
파이썬은 객체마다 자기를 가리키는 참조 수를 세는 참조 카운팅을 주로 씁니다. 수가 0이 될 때 돌려주므로 결과는 같습니다.
셋째 줄은 Rust 의 방식입니다. 값마다 주인이 되는 변수가 하나 있습니다. 주인 변수가 자기 블록을 벗어나면 그 값의 메모리가 돌려집니다. 이 규칙을 소유권이라고 부릅니다.
주인이 아닌 코드는 값을 가리키는 참조를 만들어 빌려 씁니다. 이렇게 참조를 만들어 빌리는 것을 대여라고 합니다.
컴파일러 안의 대여 검사기는 주인이 사라진 뒤에도 쓰이는 참조가 있으면 컴파일을 거절합니다. 확인이 컴파일 때 끝나므로 실행 중에는 뒷정리 비용이 없습니다.
Rust 에도 컴파일러가 따지지 못하는 일을 허락하는 unsafe 블록이 있습니다. 그 안에서는 포인터로 아무 주소나 읽고 쓸 수 있습니다.
운영체제를 직접 부르거나 C 라이브러리를 부를 때 씁니다. 그 블록 안의 메모리 안전은 다시 짜는 사람 몫입니다.
실행 환경이 지키는 방법
이 소절은 언어가 아니라 실행 환경이 메모리 안전을 지키는 경우를 봅니다. 남이 보낸 코드를 돌리는 브라우저가 그런 경우입니다.
WebAssembly 는 C 나 Rust 로 짠 코드를 브라우저에서 돌리는 형식입니다. 코드를 WebAssembly 로 번역한 프로그램 한 덩어리를 모듈이라고 합니다.
모듈은 선형 메모리라는 바이트 배열을 받아 그 안에서만 읽고 씁니다. 모든 읽기와 쓰기는 이 배열의 길이와 견줘집니다. 밖으로 나가면 실행이 멈춥니다.
그래서 C 코드 안의 버퍼 오버플로는 모듈 자기 메모리 안에서만 번집니다. 모듈 안의 데이터는 망가질 수 있습니다. 브라우저나 다른 페이지의 메모리에는 닿지 못합니다.
이처럼 안쪽 코드가 무엇을 하든 밖으로 못 나오게 실행 환경이 둘레에 쳐 둔 울타리를 샌드박스라고 합니다. 코드를 믿지 않아도 울타리가 지켜 주므로 남이 보낸 코드를 돌릴 수 있습니다.
WGSL(WebGPU Shading Language)은 브라우저가 그래픽 카드에서 돌릴 계산을 적는 언어입니다. 이 언어는 배열 범위 밖 접근의 결과를 정해 둡니다. 범위 밖 번호가 오면 배열 안의 번호로 바꿔 접근하거나, 그 접근을 없던 일로 만듭니다.
이 약속은 브라우저가 지킵니다. 브라우저는 WGSL 코드를 그래픽 카드가 받는 형태로 옮기면서 범위 확인을 끼워 넣습니다. 그래서 WGSL 코드의 인덱스 실수도 배열 밖의 메모리에는 닿지 않습니다.
대가
메모리 안전을 지키는 확인과 뒷정리에는 값이 붙습니다. 언어가 지키는 세 방법이 저마다 무엇을 내주는지 표로 봅니다.
| 방법 | 내주는 것 |
|---|---|
| 경계 검사 | 배열 접근마다 비교가 하나 붙는다. 컴파일러가 범위 안임을 증명하면 뺀다 |
| 가비지 컬렉션 | 뒷정리하는 동안 실행이 잠깐 멈추거나 [[CPU |
| 소유권 | 컴파일러가 받아 주는 모양으로 코드를 짜야 한다. 서로를 가리키는 구조는 짜기 번거롭다 |
소유권의 번거로움은 주인이 하나뿐이라는 규칙에서 옵니다. 두 값이 서로를 가리키면 어느 쪽이 주인인지 정할 수 없습니다.
그래서 운영체제 커널, 데이터베이스 엔진, 브라우저 엔진처럼 확인 하나도 아끼려는 코드는 오래 C 와 C++ 로 짜 왔습니다. 쌓인 코드가 많아 한꺼번에 다른 언어로 옮길 수도 없습니다.
이런 코드에서는 고장을 찾는 도구에 기댑니다. 무작위로 만든 입력을 끝없이 넣어 보는 퍼징이 그 하나입니다. 코드를 돌리지 않고 읽기만 해서 고장 꼴을 찾는 정적 분석도 씁니다. 새로 짜는 부분만 메모리 안전을 보장하는 언어로 옮기기도 합니다.
백엔드 개발자가 만나는 곳
자바나 파이썬 서버에서 일해도 메모리 안전을 만날 일이 있습니다. 아래 표는 그런 곳 넷을 모았습니다.
| 만나는 곳 | 모습 |
|---|---|
| 인덱스 실수 | ArrayIndexOutOfBoundsException · IndexError 로 요청 하나가 실패한다 |
| 네이티브 라이브러리 | 이미지 변환 · 압축 · 암호화처럼 C 로 짠 라이브러리 안에서 난 고장은 언어가 못 막는다 |
| 보안 공지 | 쓰는 라이브러리나 서버 프로그램에 메모리 안전 취약점이 났다는 알림이 온다 |
| 프로세스 종료 | 예외도 스택 [[트레이스 |
첫 줄은 메모리 안전이 제 일을 한 모습입니다. 예외는 버그를 알려 주지만 옆 데이터는 지켜졌습니다.
둘째 줄이 놓치기 쉬운 곳입니다. 속도가 필요한 파이썬 패키지는 C 로 짠 부분을 품고 있습니다. 자바도 네이티브 코드를 부를 수 있습니다.
언어의 보장은 그 경계에서 끝납니다. 그 너머의 고장은 예외가 아니라 넷째 줄의 프로세스 종료로 나타납니다.
메모리 누수와 가르기
메모리 누수는 다 쓴 메모리를 돌려주지 않아 쓰는 양이 계속 불어나는 고장입니다. 이름이 비슷하지만 메모리 안전 위반이 아닙니다. 남의 메모리를 건드리지 않고, 자기 메모리를 쥔 채 놓지 않을 뿐이기 때문입니다.
그래서 메모리 안전을 보장하는 언어에서도 누수가 납니다. 자바에서 전역 맵에 객체를 계속 넣기만 하면 참조가 남아 있어 가비지 컬렉션이 돌려주지 못합니다. 메모리 안전은 잘못된 접근을 막을 뿐, 쓰는 양까지 지켜 주지는 않습니다.
관련 항목
메모리 안전을 깨는 고장
버퍼 오버플로 · 범위 밖 읽기 · 해제 후 사용 · 이중 해제 · 댕글링 포인터 · 널 포인터 역참조 · 초기화되지 않은 메모리 · 스택 버퍼 오버플로 · 힙 오버플로
메모리 안전 고장이 번져 이어지는 결과
정의되지 않은 동작 · 세그멘테이션 폴트 · 임의 코드 실행 · 원격 코드 실행 · 권한 상승 · 정보 유출 · 취약점
메모리 안전을 지키는 언어 장치
경계 검사 · 가비지 컬렉션 · 참조 카운팅 · 소유권 · 대여 검사기 · 수명 · 스마트 포인터
메모리 안전을 언어가 보장하는 프로그래밍 언어
Java · Python · Go · Rust · C · Swift · Kotlin
메모리 안전 보장이 닿지 않는 코드
C · C++ · 어셈블리어 · unsafe · 네이티브 라이브러리 · 외부 함수 인터페이스
메모리 안전을 실행 환경이 지키는 장치
샌드박스 · WebAssembly · 선형 메모리 · WGSL · 가상 메모리 · 프로세스 격리
메모리 안전 고장을 찾아내는 도구
퍼징 · 정적 분석 · 동적 분석 · AddressSanitizer · Valgrind
메모리 안전 고장이 나도 공격을 어렵게 하는 방어 장치
스택 카나리 · 주소 공간 배치 난수화 · 데이터 실행 방지 · 가드 페이지
메모리 안전이 다루는 메모리 구조
메모리 · 메모리 주소 · 포인터 · 배열 · 버퍼 · 스택 · 힙 · 메모리 할당자 · JVM
메모리 안전과 헷갈리는 이웃 성질
메모리 누수 · 타입 안전성 · 스레드 안전성 · 데이터 경쟁
다른 이름: memory safety · 메모리 안전성