안전한 코딩과 로우레벨 취약점
- 5.1 안전한 프로그래밍이 어려운 이유
- 5.2 사례연구: goto fail 취약점
- 5.3 코딩 취약점
- 5.4 유력한 용의자
- 5.5 산술적 취약점
- 5.6 메모리 접근 취약점
설계가 아무리 완벽해도 구현에서 한 줄 잘못 쓰면 끝이야. 안전한 코딩이 왜 어려운지, 그리고 로우레벨에서 어떤 함정이 기다리는지 함께 보자.
보안 버그는 특별한 게 아니야. 그냥 버그인데, 공격자가 악용할 수 있느냐의 차이일 뿐이지. 근데 이게 왜 무서우냐면, 일반 버그는 프로그램이 뻗으면 바로 알아차리잖아. 보안 버그는 정상 동작하는 것처럼 보이면서 취약해. 아무도 모르는 사이에 문이 열려 있는 거야.
2014년 Apple의 goto fail 버그가 딱 그 케이스야. SSL/TLS 인증서 검증 코드에서 goto fail이 한 줄 중복됐는데, 그 한 줄 때문에 서명 검증이 통째로 건너뛰어졌어. 중간자 공격이 가능해진 거지. 중괄호 안 쓴 if문, 코드 리뷰 부재, 테스트 부족 — 이 세 가지가 겹쳐서 터진 건데, 솔직히 어느 팀에서든 일어날 수 있는 실수잖아. 정적 분석 도구 한 번 돌렸으면, 도달 불가능 코드로 바로 잡혔을 거야.
이런 실수가 반복되는 패턴이 있어. 저자가 **"유력한 용의자(Usual Suspects)"**라고 부르는 것들인데 — 신뢰 경계를 넘는 검증 안 된 데이터, 문자열 처리(포맷 스트링, 인코딩), 암호화 오용(Don't roll your own crypto), 권한 관리 미흡, 직렬화/역직렬화. 메모리 안전성 문제(버퍼 오버플로우, use-after-free, 이중 해제)는 말할 것도 없고.
결국 안전한 프로그래밍이 어려운 이유는 명확해. 방어자는 모든 경로를 막아야 하는데 공격자는 하나만 찾으면 되는 비대칭성, 보안은 "이것도 안 되고 저것도 안 된다"를 증명해야 하는 네거티브 요구사항이라 테스트가 어렵다는 것, 그리고 인간의 인지 부하가 높아질수록 실수 확률이 올라간다는 것. "조심하면 되지"가 아니라, 도구와 프로세스로 체계적으로 막아야 하는 엔지니어링 문제야. goto fail은 그 교훈을 코드 한 줄로 증명한 사례지.
이 교훈이 가장 극단적으로 드러나는 게 로우레벨 취약점이야. "고수준 언어 쓰는데 메모리 취약점이 나랑 무슨 상관이야?" 이렇게 생각할 수 있는데, 우리가 쓰는 런타임, 라이브러리, OS 커널이 전부 C/C++로 짜여 있거든. CVE 데이터베이스에서 가장 큰 비율을 차지하는 게 메모리 관련 취약점이야. 이 원리를 이해하면 왜 특정 보안 정책이 존재하는지도 납득이 가.
시작은 놀랍게도 산술이야. 32비트 정수의 최댓값 2,147,483,647에 1을 더하면 -2,147,483,648이 되잖아. 이게 메모리 할당 크기 계산에 쓰이면 끔찍한 일이 벌어져. size = count * sizeof(item)에서 count가 엄청 크면 곱셈이 오버플로우해서 아주 작은 값이 되고, 그 작은 버퍼에 큰 데이터를 쓰면서 힙 버퍼 오버플로우가 터지는 거지. 부호 변환(음수 -1이 unsigned로 4,294,967,295가 되는 것), 정수 잘림(64비트를 32비트로 캐스팅하면 상위 비트가 날아가는 것) — 산술적 함정이 메모리 취약점의 트리거가 돼.
그리고 메모리 자체의 문제들. 버퍼 오버플로우는 1988년 모리스 웜 때부터 수십 년간 가장 많이 악용된 취약점이야. 스택에서 리턴 주소를 덮어쓰면 임의 코드 실행이 가능해지지. 스택 카나리, ASLR, DEP, CFI 같은 완화 기법이 있지만, ROP 같은 고급 기법으로 우회될 수 있어. 완화는 공격을 어렵게 만들 뿐, 근본 해결은 아니야. Use-After-Free는 해제된 메모리를 계속 쓰는 건데, 브라우저 엔진에서 특히 많이 발견돼 — Chrome이랑 Firefox 보안 패치 상당수가 이 유형이야. 이중 해제, 댕글링 포인터도 마찬가지.
결국 근본적인 해결은 하나야. Rust 같은 메모리 안전 언어로의 전환. 소유권 시스템과 빌림 검사기가 컴파일 타임에 메모리 안전성을 보장하니까. Microsoft, Google, Linux 커널 커뮤니티 모두 이 방향으로 가고 있어. C/C++를 계속 써야 한다면, AddressSanitizer를 CI에 넣는 건 선택이 아니라 필수야.
정리
5장 읽고 기억할 거 세 가지:
- 보안 버그는 정상 동작처럼 보이면서 취약할 수 있어서 더 위험하다. goto fail처럼 한 줄의 실수 + 프로세스 부재가 재앙을 만들어.
- 반복되는 취약점 패턴(유력한 용의자)을 체크리스트로 활용하라. 신뢰 경계 데이터, 문자열, 암호화, 권한, 직렬화 — 코드 리뷰에서 이것만 잡아도 상당수를 예방할 수 있어.
- 메모리 안전 언어(Rust)가 유일한 구조적 해결책이다. 정수 오버플로우, 버퍼 오버플로우, Use-After-Free — 완화 기법은 우회 가능하지만, 언어 차원의 안전성은 근본적이야.