STRUCTLEAK / INIT_STACK_ALL_ZERO: 커널 구조체 및 스택 미초기화 정보 누출 방어¶
1. 개요 및 배경¶
C 언어로 작성된 리눅스 커널에서 함수의 로컬 변수(스택 메모리)는 함수 진입 시 자동으로 초기화되지 않음. 개발자가 명시적으로 memset()이나 {0} 초기화 구문을 작성하지 않으면, 스택 프레임에는 이전 함수 호출이 남겨둔 잔여 데이터(커널 포인터, 스택 카나리, 암호화 키 등)가 고스란히 남아 있게 됨.
특히 커널 내부 구조체(Structure)는 다음과 같은 두 가지 심각한 보안 사각지대를 가짐:
1. 정렬 패딩 홀(Alignment Padding Hole)의 암묵적 미초기화:
- 64비트 아키텍처(x86_64, aarch64)에서는 CPU 메모리 버스 정렬 규격(자연 정렬, Natural Alignment)에 따라 4바이트 정수 뒤에 8바이트 포인터가 올 경우, 컴파일러가 그 사이에 4바이트 패딩 공간을 자동으로 삽입함.
- 개발자가 구조체의 모든 가시적 멤버(s.id = 1; s.ptr = p;)에 값을 대입하더라도, 컴파일러가 생성한 패딩 홀에는 값이 대입되지 않아 이전 스택의 오염된 메모리가 그대로 유지됨.
- 이후 copy_to_user() 등을 통해 해당 구조체를 유저 공간으로 복사할 때, 패딩 홀에 숨겨진 커널 스택 주소나 카나리가 여과 없이 누출되어 KASLR 무력화 및 공격 페이로드 구성에 악용됨 (CVE-2013-2141 등).
2. 참조 전달(By-Reference) 시 컴파일러 경고 우회:
- 스택에 선언된 구조체의 포인터를 서브루틴(helper_init(&info))에 전달하면, 컴파일러는 해당 함수가 구조체를 초기화할 것이라고 가정하여 미초기화 변수 경고(-Wmaybe-uninitialized)를 묵살함.
- 서브루틴이 일부 필드만 초기화하고 반환하면, 나머지 필드는 스택 잔여 값을 유지한 채 유저 공간으로 전송됨 (CVE-2017-1000410 등).
리눅스 커널은 이러한 문제를 해결하기 위해 과거 PaX/Grsecurity에서 개발된 GCC 플러그인인 CONFIG_GCC_PLUGIN_STRUCTLEAK을 도입하였으며, 현대 커널(6.x LTS) 및 최신 툴체인(GCC 12+, Clang 12+)에서는 이를 네이티브 컴파일러 플래그로 표준화한 CONFIG_INIT_STACK_ALL_ZERO=y (-ftrivial-auto-var-init=zero)를 주력 방어선으로 채택함.
2. 실세계 비유: 이면지 양식과 기밀 누출¶
STRUCTLEAK 및 스택 자동 0 초기화의 방어 원리는 사무실의 이면지 재활용과 파쇄 규정에 비유할 수 있음:
[ 취약한 방식 (Baseline: CONFIG_INIT_STACK_NONE) ]
이전 기밀 회의록(스택 잔여 데이터) 이면지 뒷면에 공문서 양식 인쇄
┌────────────────────────────────────────────────────────┐
│ [헤더: 정상 작성] │ [여백(패딩): 기밀 암호 누출!] │ [본문: 정상]│
└────────────────────────────────────────────────────────┘
│ (외부 민원인에게 그대로 전달)
▼
외부인이 빛에 비춰 기밀 탈취 성공!
[ 하드닝 방식 (Hardened: CONFIG_INIT_STACK_ALL_ZERO) ]
공문서 작성 전 반드시 새 백지(0x00 소거)를 꺼내어 인쇄
┌────────────────────────────────────────────────────────┐
│ [헤더: 정상 작성] │ [여백(패딩): 0x00 0x00 깨끗함] │ [본문: 정상]│
└────────────────────────────────────────────────────────┘
│ (외부 민원인에게 전달)
▼
안전: 여백에 기밀 잔존 전무!
- 전통적 커널 (이면지 공문서):
- 함수가 호출될 때 기존 스택 메모리(이면지)를 지우지 않고 그대로 로컬 변수로 할당함.
- 빈칸(멤버 변수)에만 새 내용을 적어 외부 민원인(유저스페이스)에게 교부함. 문서의 여백(패딩 홀)이나 채우지 않은 빈 항목에는 이전 기밀 회의록(커널 주소/카나리)이 그대로 남아 있어 외부인이 손쉽게 기밀을 역추적함.
- STRUCTLEAK / INIT_STACK_ALL_ZERO (새 백지 전용 정책):
- 함수가 시작되자마자(Prologue) 스택 프레임 전체를 하얀 백지(0x00)로 완전히 밀어버린 뒤 문서를 작성함.
- 개발자가 미처 채우지 못한 여백(패딩 홀)이나 서브루틴이 빠뜨린 항목이 있더라도, 그 자리는 항상 순수한 0x00이므로 외부로의 기밀 누출이 원천 차단됨.
3. 핵심 아키텍처 및 동작 원리¶
3.1 C 구조체 정렬 패딩(Alignment Padding Hole) 메커니즘¶
64비트 아키텍처에서 컴파일러는 메모리 접근 효율을 위해 필드의 시작 주소를 해당 타입의 크기 배수로 정렬함:
struct demo_padding_leak {
uint32_t header; /* 4 바이트 (오프셋 0..3) */
/* [패딩 홀] 4 바이트 (오프셋 4..7) - 컴파일러가 8바이트 정렬을 위해 자동 삽입 */
uint64_t timestamp; /* 8 바이트 (오프셋 8..15) */
char msg[16]; /* 16 바이트 (오프셋 16..31) */
};
위 구조체에서 개발자가 다음과 같이 모든 멤버를 성실하게 초기화하더라도:
struct demo_padding_leak s;
s.header = 0x44454d4f;
s.timestamp = get_timestamp();
strncpy(s.msg, "STATUS_OK", 16);
copy_to_user(user_buf, &s, sizeof(s));
3.2 컴파일러 레벨 자동 0 초기화 (CONFIG_INIT_STACK_ALL_ZERO)¶
컴파일러 플래그 -ftrivial-auto-var-init=zero가 활성화되면, 컴파일러는 함수의 시작부(Prologue)에 로컬 스택 할당 영역 전체를 0으로 채우는 인스트럭션을 자동 삽입함.
ARM64 구현 어셈블리:¶
ARM64에서는 64비트 0 레지스터 xzr을 활용하여 128비트(16바이트) 단위로 스택을 초고속 소거함:
// 함수 프롤로그 (ARM64)
sub sp, sp, #32 // 32바이트 스택 프레임 확보
stp xzr, xzr, [sp] // [sp..sp+15]를 0으로 소거
stp xzr, xzr, [sp, #16] // [sp+16..sp+31]를 0으로 소거
// 이후 개발자의 개별 멤버 대입 코드 실행
x86_64 구현 어셈블리:¶
x86_64에서는 SSE/AVX 128비트 레지스터(%xmm0)를 0으로 리셋 후 벡터 저장하거나 rep stosq를 실행함:
// 함수 프롤로그 (x86_64)
subq $32, %rsp // 32바이트 스택 확보
xorps %xmm0, %xmm0 // %xmm0 레지스터를 0으로 초기화
movaps %xmm0, (%rsp) // [%rsp..%rsp+15]에 0 기록
movaps %xmm0, 16(%rsp) // [%rsp+16..%rsp+31]에 0 기록
이로써 패딩 홀을 포함한 모든 스택 바이트가 사전에 0x00으로 정화된 후 멤버 대입이 이루어지므로, 패딩 누출이 원천적으로 불가능해짐.
3.3 STRUCTLEAK 플러그인과의 역사적 진화¶
| 구분 | 레거시 CONFIG_GCC_PLUGIN_STRUCTLEAK |
현대 CONFIG_INIT_STACK_ALL_ZERO |
|---|---|---|
| 기반 기술 | GCC 전용 외부 플러그인 (PaX/Grsecurity 포팅) | 컴파일러 네이티브 옵션 (-ftrivial-auto-var-init=zero) |
| 초기화 범위 | _USER: __user 구조체만_BYREF: 참조 전달 구조체_BYREF_ALL: 참조 전달 모든 변수 |
스택 상의 모든 로컬 변수, 배열, 구조체 및 정렬 패딩 전체 |
| 지원 컴파일러 | GCC 전용 (플러그인 헤더 필요) | GCC 12+ 및 Clang 12+ 네이티브 지원 |
| 커널 채택 상태 | 구버전 커널에서 사용 (6.x부터 네이티브로 이관) | 최신 프로덕션 배포판 (Android, ChromeOS, Cloud Linux) 표준 |
4. 인터랙티브 아키텍처 다이어그램¶
스택 정렬 패딩 홀의 정보 누출과 컴파일러 자동 0 초기화 방어 흐름을 직관적으로 시뮬레이션한 대화형 다이어그램임:
5. 실습 및 공격/방어 시연¶
5.1 취약점 실습 드라이버 (vuln_structleak.c)¶
/proc/vuln_structleak (모드 0666) 노드를 노출하여 두 가지 핵심 누출 시나리오를 제공함:
- 모드 1 (echo 1 > /proc/vuln_structleak):
- struct demo_padding_leak (32바이트)를 스택에 할당.
- 사전에 poison_kernel_stack()으로 스택 프레임에 0x53544b5f4c45414b ("STK_LEAK") 패턴을 주입.
- 가시적 멤버(header, timestamp, msg)는 모두 할당하되 4바이트 패딩 홀은 방치.
- copy_to_user()로 유저 공간에 복사.
- 모드 2 (echo 2 > /proc/vuln_structleak):
- struct demo_byref_leak (48바이트)를 스택에 할당 후 서브루틴에 포인터 전달.
- 서브루틴은 command와 status만 할당하고 민감 비밀 필드와 카나리 영역은 방치.
5.2 비특권 사용자 익스플로잇 PoC (exploit.c)¶
일반 사용자 lab (UID 1000) 권한으로 구동되어 구조체 바이트 덤프를 분석함:
0x53544b5f 독성 스택 마커가 검출됨 ([VULNERABILITY DETECTED]).
- By-Reference 미초기화 필드에서 잔여 커널 스택 데이터가 검출됨.
- 익스플로잇 종료 코드: 42 (취약점 확인).
- 하드닝 (Protected):
- 패딩 홀 및 미초기화 필드가 모두 완벽하게 0x00000000으로 채워짐 ([PROTECTION ACTIVE]).
- 익스플로잇 종료 코드: 0 (보안 무결성 통과).
6. 듀얼 아키텍처 검증 매트릭스¶
| 아키텍처 | 커널 설정 | 패딩 홀(Offset 4..7) | By-Ref 미초기화 필드 | 판정 결과 |
|---|---|---|---|---|
| ARM64 | CONFIG_INIT_STACK_ALL_ZERO=y |
🛡️ 0x00000000 (완전 소거) | 🛡️ 0x00000000 (완전 소거) | ✅ PASS (보안 통과) |
| ARM64 | CONFIG_INIT_STACK_NONE=y |
❌ 0x53544b5f (스택 누출) | ❌ 오염 데이터 유출 | ⚠️ FAIL (취약점 노출) |
| x86_64 | CONFIG_INIT_STACK_ALL_ZERO=y |
🛡️ 0x00000000 (완전 소거) | 🛡️ 0x00000000 (완전 소거) | ✅ PASS (보안 통과) |
| x86_64 | CONFIG_INIT_STACK_NONE=y |
❌ 0x53544b5f (스택 누출) | ❌ 오염 데이터 유출 | ⚠️ FAIL (취약점 노출) |
7. 프로덕션 보안 가이드라인 및 트레이드오프¶
- 성능 오버헤드:
CONFIG_INIT_STACK_ALL_ZERO는 모든 함수 호출 시 스택 0 초기화 인스트럭션을 동반하므로 마이크로 벤치마크 기준 약 0.5% ~ 1.5%의 CPU 오버헤드가 발생함.- 그러나 SIMD 벡터 레지스터(
stp xzr/movaps)를 활용한 초고속 소거와 현대 CPU의 Store Buffer 최적화 덕분에, 실제 워크로드(웹서버, 데이터베이스, 모바일)에서의 체감 성능 영향은 무시할 수 있는 수준임. - 패턴 초기화(
INIT_STACK_ALL_PATTERN) 대비 우수성: - 디버깅 목적의
0xAA/0xFF패턴 초기화는 포인터 역참조 시 패닉을 빠르게 유발하는 장점이 있으나, 프로덕션에서는 잘못된 포인터 역참조로 인한 DoS 공격을 유발할 수 있음. - 반면 0 초기화(
INIT_STACK_ALL_ZERO)는 문자열 널 종료(\0), 널 포인터(NULL), 인덱스 0, 길이 0 등 안전한 기본값을 제공하므로 프로덕션 보안 완화책으로 가장 안전함. - 권장 설정:
- 모든 보안 강화 커널 빌드에서
CONFIG_INIT_STACK_ALL_ZERO=y를 필수 기본값으로 적용 권장함.