메모리 안전성을 실용적으로 정의하고 Fil-C, Zig, Rust의 안전 경계를 살펴봅니다.
최근:
이 모든 것을 보고, _그렇게 긴 글은 안 읽어_라고 생각할 수도 있습니다. 그리고 메모리 안전성은 정답이나 오답이 없는, 흐릿하고 논쟁적인 것1이라는 _느낌_을 받을 수도 있습니다… 그러니 이에 대해 덜 신경 써야 할까요?
하지만 그렇지 않습니다! 전혀요! 이 글이 끝날 때쯤에는, 겉보기에 모순되는 두 가지 생각을 납득시킬 수 있기를 바랍니다.
그럼, 최근의 이 소동 ☝️의 요점부터 시작해 보겠습니다.
이 lobste.rs 댓글을 바탕으로 만든 C 코드입니다.
struct User {
char name[8];
int is_root;
};
struct User* user = malloc(sizeof(struct User));
strcpy(user->name, argv[1]);
이 코드는 User를 할당하고 첫 번째 명령줄 인수에서 문자열을 복사합니다.
하지만 입력이 8자 이상이면 어떻게 될까요?
if (user->is_root) {
printf("I am root!\n");
} else {
printf("I am not root :(\n");
}
Fil-C에서도 이것은 I am root!를 출력할 수 있습니다. strcpy는 여러분이 소유한 메모리를 기꺼이 덮어쓰기 때문입니다. Fil-C는 메모리 안전성을 “악용 가능한 메모리 안전성 오류를 방지하기 위한 런타임 검사”를 갖추는 것으로 정의합니다. 위 코드는 기술적으로는 악용 가능하지 않으므로 덮어쓰기가 허용됩니다.
이 코드는 할당 영역 밖에2 쓰면 트랩을 발생시키므로, 제안된 Zig 모드는 Yolo-Zig보다 확실히 더 안전할 것입니다3. 이는 좋은 일입니다. HN의 steveklabnik 댓글도 참고하세요.
Fil-C가 이 코드를 더 메모리 안전하게 만든다면, 메모리 안전성 전체는 분명 스펙트럼이어야 합니다!
하지만 이것을 “익스플로잇”이라고 보시겠습니까? 이 코드를 “안전하다”고 보시겠습니까?
이는 큰 질문입니다. 저는 기술적으로 정확한 답을 좋아하지만, 더 실용적이라고 생각하는 “메모리 안전성이란 무엇인가”라는 질문에 대한 덜 미묘한 답을 제시하고 싶습니다.
메모리 안전 코드란 _메모리_에 대해 (1) 직관적이고 (2) 따르기 쉬운 규칙이 있는 코드입니다.
그렇지 않다고 크게 알리는 표지가 없다면 말입니다.
제 머릿속에서는 이것이 메모리 안전성의 “부력이 없는 물 법칙”이기도 합니다.

출처: Reddit
함께 보기: "부력이 없는 물은 치명적인가?", YouTube
이 정의는 물론 메모리 모델, 컴파일러, CPU를 설계하는 진짜 전문가들에게는 쓸모가 없습니다. 그들은 프로그램의 속성을 _증명_하고, 관측할 수 없는 연산을 제거하는 등의 재미있는 중요한 일을 하고 싶어 하기 때문입니다. \
그러므로 더 정확히 말하면, 적절한 기술적 메모리 안전성 정의를 갖는 일은 중요합니다. 그것은 안전한 메모리 연산이 메모리에 대한 우리의 일반적 기대와 부합하고, 올바르게 따르기 쉬운 규칙을 갖도록 보장하는 한에서 중요합니다.
경계의 안전하지 않은 쪽에는 복잡하고 심지어 난해한 규칙이 있을 수 있습니다.
하지만 안전한 쪽에도 따르기 어려운 난해한 규칙이 있다면, 그 경계를 그리는 것이 유용할까요?
단순하고 자동으로 강제되는 규칙은 복잡하고 검사되지 않는 규칙보다 올바르게 따르기 쉽습니다.4 하지만 모든 메모리 접근에 별표가 붙는다면, 애초에 “안전성”이라는 말에 무슨 의미가 있을까요?
그러므로 Fil-C가 Yolo-C보다 더 안전하고, 제안된 메모리 트랩 Zig가 Yolo-Zig보다 더 안전할지라도, 그것들은 여러분에게 중요한 유일한 방식에서는 진정으로 안전하지 않습니다.
그것들에는 여전히 여러분이 확인하고 끊임없이 의식해야 하는, 표시되지 않고 검사되지 않는 메모리 위험 요소가 있습니다. 이를 “메모리 안전”이라고 부르는 것은 사람들이 열광하고 기대하는 대문자로 쓴 “Memory Safety”라는 명칭을 다소 깎아내립니다.
Memory Safety는 훌륭합니다! 이것은 여러분을 위해 작동합니다. 컴파일러 작성자나 표준 라이브러리 설계자, 즉 다른 누군가의 삶을 더 어렵게 만들어서, 여러분이 관심 있는 일을 하며 더 나은 시간을 보낼 수 있게 합니다!
정의에는 어느 정도 미묘한 부분이 있을 수 있고, 물론 메모리 안전성과 무관하지만 체계적으로 해결할 가치가 있는 문제도 있습니다. 하지만 메모리 안전성을 갖는 요점은 전반적으로 미묘한 부분을 줄이는 데 있습니다.
또는 Ralf Jung의 표현을 맥락에서 조금 벗어나 빌리자면, “안전한 Rust를 작성할 때는 추상 기계에 대해 걱정할 필요가 없다”는 것입니다. 그리고 실제로 세상에는 그것을 걱정거리 목록에 더하지 않아도 충분히 걱정할 것이 많습니다.
Python은 메모리 안전합니다. CFFI를 사용해 원하는 무엇이든 할 수 있습니다.
from cffi import FFI
ffibuilder = FFI()
ffibuilder.cdef("...")
# Or
from ctypes import *
cdll.LoadLibrary("libc.so.6")
libc = CDLL("libc.so.6")
하지만 모든 것이 문자 그대로 C-무언가라고 불립니다. 그것이 표지판입니다.
마찬가지로 FFI나 안전하지 않은 메모리 접근이 명시적 구성 요소인 언어(unsafe extern "C"를 쓰는 Rust, P/Invoke와 unsafe를 쓰는 .Net, 또는 Java의 JNI)는 안전할 수 있습니다.
/proc/self/mem다음처럼 메모리에 쓸 수 있게 한다고 해서 언어가 안전하지 않은 것은 아닙니다.
let mut f = fs::OpenOptions::new()
.write(true)
.open("/proc/self/mem").unwrap();
그렇습니다. 이것으로 정말 끔찍한 메모리 범죄를 저지를 수 있습니다. 하지만 분명한 표지가 있습니다. /proc/self/mem은 “모든 보장은 사라진다”고 말하는 훌륭한 방법입니다.
이것은 논쟁거리가 아니지만, 이전 글을 참고하세요.