TL;DR

  • 작은 로컬 변수에 제로화(zeroization)를 추가하면 컴파일러가 값을 레지스터에만 두거나 스택 사본을 새로 만들 수 있어, 원래 코드보다 비밀 사본이 늘고 실제 값은 남을 수 있음.
  • 일반 memset()은 최적화 과정에서 제거될 수 있으며, 값이 레지스터에만 있으면 메모리의 같은 위치를 0으로 덮어도 레지스터 값은 지워지지 않음.
  • volatile 바이트 저장은 메모리에 0을 쓰지만 대상 값이 메모리에 없을 수 있고, 별도 함수 호출은 주소를 전달하는 과정에서 스택 사본을 만들 수 있음.
  • 태그 비교 사례에서는 컴파일러가 호출 뒤로 비교를 미루고 스택 사본을 남겨, 제로화한 객체 외의 사본이 함수 반환 시점에도 남음.
  • 이미 메모리에 있는 비밀번호 버퍼나 폐기할 키 스케줄을 지우는 일은 유용하지만, 작은 로컬 변수는 최적화된 빌드와 LTO·보안 강화 플래그를 포함해 실제 저장 위치와 호출 간 생존 여부를 확인해야 함.

`memset()`부터 살펴보기

  • 비밀을 처리한 뒤 지우는 일은 기본적인 위생 수칙이며, 프로젝트에는 비밀처럼 보이는 모든 값에 제로화 함수를 추가하자는 풀 리퀘스트(PR)가 많이 올라옴.
  • 이런 변경은 쉽게 발견할 수 있고 대형 언어 모델(LLM)이 자주 제안하며 이론상 유용해 보이지만, 무조건 값을 0으로 덮으면 오히려 해가 될 수 있음.
  • 제로화 코드를 추가한 결과 원래 코드에는 없던 비밀 사본이 생기고, 함수가 반환된 뒤에도 남을 수 있음.
  • 예시 코드와 컴파일 결과는 Godbolt 예제에서 확인할 수 있음.
  • 첫 예제는 입력값을 중간값으로 변환해 결과를 계산한 뒤, 반환 전에 중간값을 memset()으로 지우는 함수임.
  • memset()을 제거한 코드와 비교하면 생성된 명령어가 동일함. 함수 반환 뒤 다시 읽히지 않는 중간값을 지우는 작업을 컴파일러가 불필요하다고 판단해 제거함.
  • 어셈블리에서 중간값은 메모리에 저장되지 않고 레지스터에만 머묾.

메모리 쓰기를 유지하기

  • 흔한 대응은 memset() 대신 volatile 바이트 포인터를 사용해 각 바이트에 0을 저장하는 방식임.
  • Xcode에 포함된 Clang 21의 ARM64 출력에는 0을 스택에 쓰는 strb 명령어 8개가 나타남. 0 값은 wzr 레지스터에서 가져옴.
  • 하지만 중간값을 해당 스택 위치에 저장하는 명령어는 없음. 중간값은 x8에 남아 있고, 코드는 값이 들어 있지 않은 위치에 0을 씀.
  • 함수는 x8의 중간값으로 결과를 계산해 반환하며 레지스터 값은 지우지 않음. 따라서 CPU 사이클을 소비해도 실제 비밀값은 남음.

컴파일러가 보지 못하게 제로화하기

  • 또 다른 흔한 방식은 제로화 코드를 별도 파일에서 컴파일하고 링크 시간 최적화(LTO)를 끈 뒤, opaque_wipe() 같은 함수를 호출하는 것임.
  • 호출부에는 함수 선언만 보이므로 컴파일러는 함수 구현을 확인할 수 없음. 함수는 전달된 주소의 바이트를 volatile 저장으로 0으로 덮음.
  • 이 경우 호출 전에 str x8, [sp, #8] 명령어가 새로 나타남. 제로화 함수에 주소를 전달해야 하고, 호출부가 구현을 볼 수 없어 함수가 해당 주소의 기존 값을 읽을 가능성도 고려해야 하기 때문임.
  • 컴파일러는 중간값을 스택에 먼저 저장하고, 제로화 함수는 새 스택 사본을 지움. 레지스터 사본은 그대로 남음.
  • 이 방식은 값이 메모리에 존재하는 시간을 새로 만들고 그 사본을 지우는 비용까지 들이지만 레지스터 사본은 유지함.
  • 쓰기 전용 동작으로 인식되는 경우에는 해당 저장이 생략될 수 있으며, 요즘 흔한 LTO를 켜면 별도 파일로 구현을 숨기려는 시도도 효과가 없어질 수 있음.

태그를 비교해 지우기

  • 다음 사례는 HMAC 출력 같은 태그를 계산하고, 애플리케이션이 제공한 태그와 비교한 뒤, 계산된 태그를 지우고 결과를 반환하는 흐름임.
  • 설명을 쉽게 하기 위해 계산 태그를 seed[i] ^ 0x5a로 정의함. 이는 전혀 안전하지 않은 계산이지만 어셈블리를 따라가기 쉬운 예시임.
  • C 코드에서는 16바이트 태그를 계산하고, 각 바이트와 후보 태그의 차이를 누적해 동일 여부를 구한 다음 opaque_wipe()로 계산 태그를 지움.
  • 호출부와 제로화 함수를 LTO 없이 각각 최적화해 컴파일하면, 어셈블리에서 XOR 뒤 q0을 스택에 두 번 저장하고 제로화 함수 호출 뒤에 cmeq 비교를 수행함.
  • 스택을 할당한 뒤 스택 포인터를 S, 프레임 포인터를 x29 = S + 64라고 할 때 스택 구성은 다음과 같음.
  • S부터 S + 15: 후보 태그, 지우지 않음.
  • S + 16부터 S + 31: 계산 태그의 추가 사본, 지우지 않음.
  • S + 40부터 S + 55: 주소가 전달된 계산 태그 객체, 지움.
  • 컴파일러는 호출 전에 비교 피연산자를 불러오지만 비교 연산 자체는 호출 뒤로 미룸. 호출을 넘어 계산 태그를 유지하려고 스택 임시 저장 공간(spill slot)에 사본을 만듦.
  • 제로화 함수는 전달된 16바이트 객체를 정상적으로 지우지만, 이후 ldp가 다른 사본을 다시 불러오고 cmeq가 비교함. 스택 임시 저장 공간은 함수 반환 전까지 지워지지 않음.
  • 제로화 호출을 제거하고 다시 컴파일하면 계산 태그는 더 이상 스택에 저장되지 않고 레지스터에만 머묾. 제로화가 남겨 둔 사본을 새로 만든 셈임.
  • 이는 컴파일러 버그가 아님. 반환값은 정확하고 지정된 객체는 덮어쓰지만, C 언어는 두 동작 사이에 의도한 보안 순서를 보장하지 않음.
  • Godbolt에서 두 함수를 비교할 수 있음. 해당 출력에서는 주소가 지정된 객체가 Xcode의 sp + 40이 아니라 sp + 32에 있지만, sp + 16의 추가 사본은 제로화 뒤에도 남음.
  • 스택 보호를 끄면 다른 코드가 생성되지만, 이는 우연하고 신뢰할 수 없는 부수 효과일 뿐 해결책이나 권장 방법이 아님.
  • 따라서 제로화로 저장 비용을 치르거나 함수 호출과 스택 사본을 새로 만들면서도 비밀값이 남는 경우가 생김.

레지스터도 무시하면 안 됨

  • 문맥 전환(context switch) 때 레지스터는 메모리에 복사되며, 코어 덤프·최대 절전 모드 파일·가상 머신 스냅샷에서도 레지스터 값이 노출될 수 있음.
  • Zenbleed 같은 마이크로아키텍처 버그를 통해 레지스터 값이 유출될 수도 있음.
  • SIMD 레지스터 하나에는 256비트 비밀 키 전체나 512비트 해시까지 담길 수 있음. SIMD 레지스터는 범용 레지스터보다 재사용 빈도가 낮은 경향도 있음.
  • 전통적인 제로화 함수는 메모리에 0을 쓰도록 설계돼 실제 실행 흐름은 고려하지 않음.

유용한 제로화는 유지하기

  • 비밀번호 버퍼의 제로화를 중단하자는 뜻은 아님. 이런 버퍼는 이미 메모리에 존재하므로 사용을 마친 뒤 지우는 일이 유용함. 할당된 키 스케줄이나 폐기할 컨텍스트도 마찬가지임.
  • 앞선 예시에서는 보호하려는 값이 다른 위치에도 존재했음.
  • 작은 로컬 변수에 제로화를 추가하기 전에 값이 이미 메모리에 있는지, 주소를 취하는 동작이 새 저장 공간을 만드는지, 호출 중 무엇이 살아남는지 확인해야 함.
  • LTO와 보안 강화 플래그를 포함해 실제 배포하는 최적화 빌드를 검사해야 함.
  • 버퍼 제로화 함수를 무작정 곳곳에 추가하는 일을 멈추는 편이 나을 수 있으며, 비밀을 지우는 더 신뢰할 수 있는 기법은 2부에서 다룰 예정임.