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부에서 다룰 예정임.
댓글 (0)
로그인하면 이 기사에 내 생각을 남길 수 있어요