Lobsters

Zeroization, part 1: Wiping can make things worse

제로화 1부: 메모리 지우기가 오히려 상황을 악화할 수 있습니다

비밀값을 지우려고 넣은 코드가 컴파일러 최적화나 함수 호출 규약 때문에 효과를 내지 못하고, 오히려 메모리에 복사본을 만들기도 합니다. 글은 C와 arm64 컴파일 결과를 비교하며, 버퍼 삭제는 계속하되 작은 지역 변수는 실제로 어디에 저장되고 무엇이 남는지 확인하라고 설명합니다.

AI 요약

비밀값을 사용한 뒤 지우는 일은 보안 위생의 기본으로 여겨집니다. 하지만 지우기 함수를 무턱대고 추가하면 값이 사라지기는커녕 기존 코드에는 없던 복사본이 생길 수 있습니다. 이 글은 C 코드와 최적화된 arm64 어셈블리를 비교해, 컴파일러가 비밀값을 레지스터에 둔 경우와 지우기 함수 호출이 값을 메모리에 저장하도록 만드는 경우를 살펴봅니다.

memset은 최적화로 사라질 수 있습니다

예제에서는 입력값으로 중간값을 계산하고, 그 값으로 결과를 만든 뒤 memset()으로 중간값을 0으로 지웁니다. 하지만 반환 뒤에는 중간값을 다시 읽지 않으므로 컴파일러가 이 지우기를 불필요한 연산으로 판단해 제거합니다. memset()을 넣은 코드와 뺀 코드의 기계어가 같으며, 중간값도 메모리가 아닌 레지스터에 남습니다.

volatile 쓰기도 지우려는 값을 지우지 못할 수 있습니다

흔히 쓰는 우회책은 volatile unsigned char 포인터로 바이트마다 0을 기록하는 방법입니다. Xcode에 포함된 Clang 21로 arm64 코드를 만들면 스택에 0을 쓰는 strb 명령 여덟 개가 나옵니다. 이 쓰기 자체는 최적화되지 않았지만, 중간값을 스택에 저장하는 명령은 없습니다. 값은 여전히 x8 레지스터에 있습니다. 결국 코드가 0으로 덮은 곳에는 원래 비밀값이 없고, 레지스터에 남은 값도 지워지지 않습니다.

별도 함수로 지우기를 감춰도 복사본이 생길 수 있습니다

지우기 코드를 별도 파일의 opaque_wipe() 함수에 두고 LTO(Link Time Optimization) 없이 호출하면, 호출부 컴파일러는 함수 내용을 알 수 없습니다. 함수가 전달받은 주소의 기존 값을 읽을 수 있다고 보므로, 레지스터에 있던 중간값을 먼저 스택에 저장합니다. 지우기 함수는 이 스택 복사본을 지우지만 레지스터 값은 그대로 남습니다. 지우려고 추가한 코드가 값이 메모리에 존재하는 시간을 만들고, 지울 대상의 복사본까지 늘린 셈입니다. 쓰기 전용 연산임을 컴파일러가 알아보면 저장을 생략할 수 있고, LTO를 켜면 별도 파일로 구현을 감춘 효과도 사라질 수 있습니다.

태그 비교 예제에서는 지운 뒤에도 스필 복사본이 남습니다

글은 16바이트 태그를 seed[i] ^ 0x5a로 계산하고, 애플리케이션이 제공한 후보 태그와 비교한 다음 computed 배열을 지우는 예제를 제시합니다. 이 간단한 태그 계산은 어셈블리 흐름을 보여주기 위한 것이며 안전한 태그 생성 방식은 아닙니다. C 코드에서는 비교가 지우기보다 먼저 나오지만, 컴파일 결과에서는 지우기 함수 호출 뒤에 비교 명령이 놓입니다.

컴파일러는 계산한 태그를 스택의 두 위치에 저장합니다. 하나는 opaque_wipe()가 주소로 받아 지우는 배열이고, 다른 하나는 함수 호출 뒤 비교에 쓸 스필(spill) 복사본입니다. 비교 피연산자를 미리 불러온 뒤 실제 비교를 미룬 탓에 복사본이 필요합니다. 지우기 함수는 요청받은 16바이트를 제대로 덮지만, 비교에 쓰는 다른 복사본은 함수가 반환할 때까지 남습니다. 반대로 지우기를 없애고 다시 컴파일하면 태그가 스택에 저장되지 않고 레지스터에만 머뭅니다. 지우기 호출이 없던 코드보다 복사본을 더 만든 사례입니다.

저자는 이를 컴파일러 버그라고 보지 않습니다. 반환값은 맞고, 지정한 객체도 덮어썼으며, C 언어는 개발자가 의도한 보안상 순서를 보장하지 않습니다. 스택 보호 설정을 끄면 코드가 달라지지만, 우연히 생기는 불안정한 부수 효과일 뿐 해결책은 아니라고 덧붙입니다.

메모리뿐 아니라 레지스터도 고려해야 합니다

레지스터 값은 문맥 전환 때 메모리로 복사될 수 있고, 코어 덤프(core dump), 최대 절전 모드 파일, 가상 머신 스냅샷에도 드러날 수 있습니다. Zenbleed 같은 마이크로아키텍처 취약점으로 새어 나올 가능성도 있습니다. SIMD 레지스터 하나에 256비트 비밀 키나 512비트 해시 전체가 들어가기도 합니다. 하지만 전통적인 제로화 함수는 메모리에 0을 쓰는 데 초점을 맞추므로 실제 실행 흐름이나 레지스터 값은 알지 못합니다.

유용한 지우기는 유지하고, 작은 지역 변수는 확인합니다

그렇다고 비밀번호 버퍼를 지우지 말라는 뜻은 아닙니다. 버퍼는 애초에 메모리에 있으므로 사용이 끝난 뒤 덮어쓰는 편이 유용합니다. 할당한 키 스케줄이나 더는 쓰지 않는 컨텍스트도 마찬가지입니다. 반면 작은 지역 변수에 지우기 함수를 추가하기 전에는 값이 이미 메모리에 있는지, 주소를 넘기느라 저장 공간이 생기는지, 함수 호출 동안 어떤 값이 살아남는지 살펴야 합니다. LTO와 하드닝 옵션을 포함해 실제 배포할 최적화 빌드도 확인하라고 권합니다. 글은 더 신뢰할 수 있는 비밀값 삭제 기법을 2부에서 다루겠다고 예고합니다.

Lobsters 반응

  • @DMorsing — 제가 작업했던 runtime/secret 같은 방향을 준비하는 글처럼 느껴집니다. GCC의 strub 속성(attribute)은 어느 정도 해결하지만 다른 문제도 있습니다. 시그널이 전달되면 레지스터가 시그널 스택에 저장됩니다. 운영체제나 언어 런타임이 조정하지 않으면 그 값은 얼마나 오래 남을지 알 수 없습니다.
  • @edwintorok — 해결책은 이 Mastodon 게시물에 있습니다. GCC의 strub 속성이 있고, 스택 등에 있는 값도 지웁니다.
  • @Garbi — memset 같은 특정 코드가 최적화되지 않도록 컴파일러 지시자를 제공하는 프로그래밍 언어가 있으면 좋겠습니다.
    • @masklinn — 글에서 충분히 보여주듯, 지우기가 실제로 실행되더라도 스택 저장이나 추가 복사본을 만들어 오히려 해를 끼칠 수 있습니다. 문제는 필요한 의도가 컴파일러에 전달되지 않는다는 점입니다. 컴파일러를 속이는 방식으로는 계속 문제가 생깁니다. 잘못된 연산을 최적화하지 않는다고 올바른 결과가 나오는 것은 아닙니다. Simon, Chisnall, Anderson이 거의 10년 전 「What You Get Is What You C」에서 지적했듯, 영원히 컴파일러를 속이려 하기보다 원하는 동작을 명확히 전달하는 편이 낫습니다.
    • @olliej — 네, 이상적인 해결책은 값을 어디에 두었든 백엔드까지 전달되는 일종의 scrub 한정자나 속성일 겁니다.
    • @olliej — memset_s가 그 목적을 맡습니다. 다만 글에서 다루는 문제는 값이 메모리에 없을 때도 memset[_s] 호출이 지우기 위해 값을 스택에 저장하게 만든다는 점입니다.
    • @fanf — 올바른 답은 memset_explicit()이어야 합니다. 글에서 표준 해법이나 explicit_bzero() 같은 표준화 전 함수가 언급되지 않아 아쉽습니다. 다만 Clang과 GCC는 memset_explicit()을 충분히 이해하지 못해 스택 저장을 피하지 못하는 듯하므로, 그 누락이 큰 차이를 만들지는 않는다고 봅니다. memset_s()는 Annex K의 이식성 문제와 복잡한 런타임 제약 검사 및 예외 처리에 얽혀 있어 컴파일러가 합리적인 내장 함수로 구현하기 어렵습니다. memset_s()는 memset()과 달리 추상 기계 규칙에 따라 호출을 반드시 평가해야 합니다. 따라서 s와 n이 가리키는 메모리를 나중에도 접근할 수 있고 c 값이 들어 있다고 가정해야 합니다. memset_explicit()의 목적은 객체에 저장된 민감한 정보를 접근 불가능하게 만드는 것입니다. 각주에는 최적화 여부와 무관하게 메모리 쓰기를 항상 수행한다는 취지가 적혀 있습니다.
    • @spc476 — 컴파일러가 memset_s() 호출을 특별하게 처리해 변수가 메모리에 있든 레지스터에 있든 값을 지우도록 하면 됩니다. C 컴파일러는 이미 <string.h>를 포함했을 때 문맥에 따라 memset(), memcpy(), memmove()를 인라인하거나 함수 호출로 처리할 수 있습니다.
    • @olliej — 컴파일러는 memset의 의미를 알고 있어 사용하지 않는 쓰기로 판단할 수 있습니다. memset_s와 다른 _s 함수는 쓰이지 않는 값이어도 쓰기를 보장하는 의미를 갖습니다. 이를 낮은 수준 코드로 바꿔 실제로 지우지 않는 컴파일러는 표준을 따르지 않는 셈입니다. 그래서 컴파일러는 이 함수들을 사실상 특별 취급하지 않습니다.
    • @spc476 — 변수가 보통 레지스터에만 저장되더라도 memset_s()는 메모리에 써야 하나요? 변수가 volatile로 표시되지 않아도요?

원문: 00f.net / 번역·요약: Trawling