이미지 로딩 중...
AI Generated
2025. 11. 14. · 3 Views
Rust로 만드는 나만의 OS 메모리 보호와 권한 시스템
운영체제의 핵심인 메모리 보호 메커니즘과 권한 관리 시스템을 Rust로 구현하는 방법을 다룹니다. 페이지 테이블, 권한 비트, 사용자/커널 모드 전환 등 실제 OS에서 사용되는 메모리 보호 기법을 실습합니다.
목차
- 페이지 테이블 엔트리와 권한 비트
- 사용자 모드와 커널 모드 전환
- 메모리 영역별 권한 설정
- 커널 메모리 보호와 격리
- 동적 권한 변경과 mprotect
- 페이지 폴트 핸들러와 권한 검증
- SMAP과 SMEP 하드웨어 보호
- 메모리 태깅과 MTE (Memory Tagging Extension)
1. 페이지 테이블 엔트리와 권한 비트
시작하며
여러분이 운영체제를 개발할 때 이런 상황을 겪어본 적 있나요? 사용자 프로그램이 커널 메모리 영역에 접근해서 시스템 전체가 망가지는 상황.
또는 읽기 전용이어야 할 코드 영역에 악의적인 프로그램이 데이터를 쓰는 경우. 이런 문제는 실제 개발 현장에서 보안 취약점의 주요 원인이 됩니다.
메모리 보호가 제대로 되지 않으면 버퍼 오버플로우, 권한 상승 공격 등 심각한 보안 문제가 발생할 수 있습니다. 하나의 프로그램 오류가 전체 시스템을 다운시킬 수도 있죠.
바로 이럴 때 필요한 것이 페이지 테이블 엔트리(PTE)의 권한 비트입니다. CPU 레벨에서 메모리 접근을 제어하여 각 페이지마다 읽기, 쓰기, 실행 권한을 세밀하게 관리할 수 있습니다.
개요
간단히 말해서, 페이지 테이블 엔트리는 가상 메모리 주소를 물리 메모리 주소로 변환하는 동시에 해당 페이지의 접근 권한을 정의하는 구조체입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 현대 운영체제는 여러 프로세스가 동시에 실행되는 환경에서 각 프로세스가 서로의 메모리를 침범하지 못하도록 보호해야 합니다.
예를 들어, 웹 브라우저가 악성 JavaScript를 실행할 때 시스템 메모리에 접근하지 못하도록 차단하는 경우에 매우 유용합니다. 전통적인 방법과의 비교를 하자면, 기존에는 세그먼테이션을 사용해 메모리를 보호했다면, 이제는 페이징 시스템의 권한 비트를 통해 더 세밀하고 효율적인 보호가 가능합니다.
페이지 테이블 엔트리의 핵심 특징은 크게 세 가지입니다. 첫째, Present 비트로 페이지가 메모리에 있는지 확인하고, 둘째, Writable 비트로 쓰기 가능 여부를 제어하며, 셋째, User/Supervisor 비트로 사용자 모드에서 접근 가능한지를 결정합니다.
이러한 특징들이 하드웨어 레벨에서 메모리 보호를 강제하기 때문에 소프트웨어만으로는 우회할 수 없는 강력한 보안을 제공합니다.
코드 예제
use bitflags::bitflags;
bitflags! {
/// 페이지 테이블 엔트리의 권한 비트 플래그
pub struct PageTableFlags: u64 {
const PRESENT = 1 << 0; // 페이지가 메모리에 존재
const WRITABLE = 1 << 1; // 쓰기 가능
const USER_ACCESSIBLE = 1 << 2; // 사용자 모드 접근 가능
const WRITE_THROUGH = 1 << 3; // 쓰기 직통 캐싱
const NO_CACHE = 1 << 4; // 캐시 비활성화
const ACCESSED = 1 << 5; // CPU가 접근한 적 있음
const DIRTY = 1 << 6; // 페이지가 수정됨
const HUGE_PAGE = 1 << 7; // 2MB/1GB 대형 페이지
const GLOBAL = 1 << 8; // TLB에서 제거되지 않음
const NO_EXECUTE = 1 << 63; // 실행 불가 (NX 비트)
}
}
#[repr(transparent)]
pub struct PageTableEntry(u64);
impl PageTableEntry {
/// 새 페이지 테이블 엔트리 생성
pub fn new(addr: PhysAddr, flags: PageTableFlags) -> Self {
// 물리 주소와 플래그를 결합하여 엔트리 생성
PageTableEntry((addr.as_u64() & 0x000f_ffff_ffff_f000) | flags.bits())
}
/// 엔트리의 플래그 확인
pub fn flags(&self) -> PageTableFlags {
PageTableFlags::from_bits_truncate(self.0)
}
/// 쓰기 가능 여부 확인
pub fn is_writable(&self) -> bool {
self.flags().contains(PageTableFlags::WRITABLE)
}
}
설명
이것이 하는 일: 페이지 테이블 엔트리는 64비트 정수 하나에 물리 메모리 주소(상위 52비트)와 권한 플래그(하위 12비트)를 함께 저장합니다. CPU의 MMU(Memory Management Unit)가 이 정보를 읽어 메모리 접근을 제어합니다.
코드를 단계별로 나누어 설명하겠습니다. 첫 번째로, bitflags!
매크로를 사용해 PageTableFlags를 정의합니다. 각 비트는 x86-64 아키텍처의 페이지 테이블 명세를 따르며, PRESENT 비트(bit 0)는 페이지가 실제 메모리에 로드되어 있는지, WRITABLE 비트(bit 1)는 쓰기가 가능한지, USER_ACCESSIBLE 비트(bit 2)는 사용자 모드에서 접근 가능한지를 나타냅니다.
이렇게 비트 단위로 관리하는 이유는 하드웨어 레벨에서 효율적으로 처리하기 위함입니다. 그 다음으로, PageTableEntry 구조체가 실행되면서 #[repr(transparent)] 속성을 통해 내부의 u64와 동일한 메모리 레이아웃을 보장합니다.
new 메서드는 물리 주소의 하위 12비트를 마스킹하여(페이지는 4KB 정렬이므로 하위 12비트는 항상 0) 제거하고, 그 자리에 권한 플래그를 OR 연산으로 삽입합니다. 마지막으로, flags()와 is_writable() 같은 헬퍼 메서드들이 엔트리에서 권한 정보를 추출하여 최종적으로 코드에서 쉽게 권한을 확인할 수 있게 합니다.
from_bits_truncate는 알 수 없는 비트가 설정되어 있어도 안전하게 처리합니다. 여러분이 이 코드를 사용하면 타입 안전성을 유지하면서도 하드웨어 레벨의 메모리 보호 메커니즘을 직접 제어할 수 있습니다.
Rust의 타입 시스템 덕분에 잘못된 플래그 조합을 컴파일 타임에 잡아낼 수 있고, bitflags 크레이트의 비트 연산 지원으로 플래그를 조합하거나 확인하는 작업이 매우 직관적입니다. 또한 NO_EXECUTE 비트를 활용하면 데이터 영역에서 코드 실행을 방지하여 코드 인젝션 공격을 막을 수 있습니다.
실전 팁
💡 커널 코드 영역은 PRESENT | WRITABLE 플래그 없이 설정하여 읽기 전용으로 만들어야 합니다. 이렇게 하면 버그로 인해 커널 코드가 수정되는 것을 방지할 수 있습니다.
💡 사용자 힙 메모리는 USER_ACCESSIBLE | WRITABLE | NO_EXECUTE로 설정하세요. 데이터는 쓸 수 있지만 실행은 불가능하게 만들어 보안을 강화합니다.
💡 ACCESSED와 DIRTY 비트는 CPU가 자동으로 설정하므로 페이지 교체 알고리즘(LRU 등)에 활용할 수 있습니다. 주기적으로 이 비트들을 확인하고 초기화하면 어떤 페이지가 실제로 사용되는지 추적 가능합니다.
💡 GLOBAL 비트는 커널 페이지에만 설정하세요. 컨텍스트 스위칭 시 TLB 플러시를 피할 수 있어 성능이 향상됩니다.
💡 디버깅 시에는 페이지 폴트 핸들러에서 엔트리의 플래그를 출력하도록 하세요. "쓰기 시도했는데 WRITABLE 비트가 없음" 같은 정보가 버그 추적에 매우 유용합니다.
2. 사용자 모드와 커널 모드 전환
시작하며
여러분이 시스템 콜을 구현할 때 이런 고민을 해본 적 있나요? 사용자 프로그램이 파일을 읽거나 네트워크 패킷을 보낼 때, 어떻게 안전하게 커널 기능에 접근하도록 할 것인가?
직접 접근을 허용하면 보안 문제가 생기고, 막으면 아무것도 할 수 없게 됩니다. 이런 문제는 모든 운영체제가 해결해야 하는 핵심 과제입니다.
사용자 프로그램은 제한된 권한으로 실행되어야 하지만, 필요할 때는 커널의 강력한 기능을 사용할 수 있어야 합니다. 이 균형을 잘못 맞추면 보안 취약점이 생기거나 시스템이 쓸모없어집니다.
바로 이럴 때 필요한 것이 권한 레벨 전환(Privilege Level Transition) 메커니즘입니다. CPU의 링 레벨을 활용해 사용자 모드(Ring 3)와 커널 모드(Ring 0) 사이를 안전하게 전환하면서 필요한 기능만 제공할 수 있습니다.
개요
간단히 말해서, 사용자 모드와 커널 모드 전환은 CPU의 권한 레벨을 변경하여 제한된 사용자 공간에서 모든 권한을 가진 커널 공간으로 안전하게 이동하는 메커니즘입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 사용자 프로그램이 직접 하드웨어를 제어하거나 다른 프로세스의 메모리에 접근하면 시스템이 불안정해집니다.
예를 들어, 웹 서버가 소켓을 생성할 때 시스템 콜을 통해 커널 모드로 전환한 뒤 네트워크 스택에 접근하고, 완료되면 다시 사용자 모드로 돌아오는 과정이 필수적입니다. 전통적인 방법과의 비교를 하자면, 기존에는 소프트웨어 인터럽트(int 0x80)를 사용해 시스템 콜을 호출했다면, 이제는 syscall/sysret 명령어를 사용해 훨씬 빠르게 모드 전환을 수행할 수 있습니다.
모드 전환의 핵심 특징은 다음과 같습니다. 첫째, 스택을 사용자 스택에서 커널 스택으로 자동 전환하여 사용자가 커널 데이터를 조작하지 못하게 합니다.
둘째, 레지스터 상태를 저장하고 복원하여 시스템 콜 전후로 사용자 프로그램이 정상 동작하도록 보장합니다. 셋째, 인터럽트를 제어하여 전환 과정이 중단되지 않도록 원자적으로 수행합니다.
이러한 특징들이 사용자 프로그램의 안전한 실행과 커널 보호를 동시에 달성하게 합니다.
코드 예제
use x86_64::structures::idt::InterruptStackFrame;
use x86_64::registers::control::Cr3;
/// 시스템 콜 핸들러 - 사용자 모드에서 커널 모드로 진입
#[no_mangle]
pub extern "C" fn syscall_handler(stack_frame: &mut InterruptStackFrame) {
// 1. 사용자 모드에서 전달된 시스템 콜 번호와 인자 읽기
let syscall_number = stack_frame.cpu_flags; // rax 레지스터
// 2. 현재 페이지 테이블 저장 (사용자 공간)
let (user_page_table, _) = Cr3::read();
// 3. 커널 페이지 테이블로 전환
unsafe {
Cr3::write(kernel_page_table(), Cr3Flags::empty());
}
// 4. 커널 모드에서 시스템 콜 실행
let result = match syscall_number {
1 => sys_write(), // 파일 쓰기
2 => sys_read(), // 파일 읽기
3 => sys_open(), // 파일 열기
_ => Err(SyscallError::InvalidSyscall),
};
// 5. 결과를 사용자 모드로 반환할 레지스터에 저장
stack_frame.cpu_flags = result.unwrap_or_else(|e| e.code());
// 6. 사용자 페이지 테이블로 복귀
unsafe {
Cr3::write(user_page_table, Cr3Flags::empty());
}
// 7. iretq 명령어가 자동으로 사용자 모드로 복귀
}
설명
이것이 하는 일: 시스템 콜 핸들러는 사용자 프로그램이 syscall 명령어를 실행할 때 자동으로 호출되며, CPU가 하드웨어 레벨에서 커널 모드로 전환한 상태에서 실행됩니다. InterruptStackFrame은 CPU가 자동으로 저장한 사용자 모드의 레지스터 상태를 담고 있습니다.
코드를 단계별로 나누어 설명하겠습니다. 첫 번째로, 사용자가 전달한 시스템 콜 번호를 읽습니다.
syscall 명령어를 실행하기 전에 사용자는 rax 레지스터에 시스템 콜 번호를, rdi, rsi, rdx 등에 인자를 넣어둡니다. CPU가 커널로 진입하면서 이 값들을 스택 프레임에 보존하기 때문에 안전하게 읽을 수 있습니다.
그 다음으로, 페이지 테이블을 전환하는 과정이 실행됩니다. 사용자 모드에서는 사용자 프로세스의 페이지 테이블을 사용하지만, 커널 코드를 실행하려면 커널 메모리에 접근할 수 있는 커널 페이지 테이블로 바꿔야 합니다.
Cr3::read()로 현재 페이지 테이블을 저장해두고, Cr3::write()로 커널 페이지 테이블을 로드합니다. 이 작업은 unsafe 블록 안에서 수행되는데, 잘못 설정하면 메모리 접근 오류가 발생할 수 있기 때문입니다.
세 번째 단계에서는 실제 시스템 콜을 처리합니다. match 표현식으로 시스템 콜 번호에 따라 적절한 커널 함수를 호출합니다.
예를 들어 syscall_number가 1이면 파일 쓰기를 담당하는 sys_write()를 실행합니다. 각 시스템 콜 함수는 Result 타입을 반환하여 성공/실패를 명확히 표현합니다.
마지막으로, 시스템 콜 결과를 사용자 모드로 전달하고 복귀하는 과정이 진행됩니다. 결과값을 stack_frame.cpu_flags(rax 레지스터에 해당)에 저장하면 사용자 프로그램은 syscall 명령어 다음 줄에서 이 값을 읽을 수 있습니다.
페이지 테이블을 다시 사용자 페이지 테이블로 되돌린 후, 함수가 종료되면 CPU가 iretq 명령어를 실행하여 자동으로 Ring 3로 복귀합니다. 여러분이 이 코드를 사용하면 사용자 프로그램과 커널 사이의 안전한 인터페이스를 만들 수 있습니다.
사용자는 syscall 명령어 하나로 파일 I/O, 네트워크 통신, 프로세스 생성 등 모든 커널 기능에 접근할 수 있지만, 커널은 시스템 콜 번호를 검증하고 인자를 확인하여 악의적인 접근을 차단할 수 있습니다. 또한 페이지 테이블 전환으로 사용자가 커널 메모리를 직접 읽거나 쓰는 것을 원천적으로 막을 수 있습니다.
실전 팁
💡 syscall 명령어를 사용하기 전에 IA32_LSTAR MSR 레지스터에 핸들러 주소를 설정해야 합니다. wrmsr 명령어로 설정하며, 부팅 시 한 번만 수행하면 됩니다.
💡 시스템 콜 핸들러에서는 절대로 사용자가 전달한 포인터를 믿지 마세요. 커널 메모리를 가리키는 포인터를 전달해 커널 데이터를 조작하는 공격이 가능하므로, 모든 포인터는 사용자 공간 범위 내에 있는지 검증해야 합니다.
💡 스택 전환은 TSS(Task State Segment)에 설정된 커널 스택 주소를 사용합니다. 각 CPU 코어마다 별도의 TSS를 설정해야 멀티코어 환경에서 안전합니다.
💡 시스템 콜 처리 중 인터럽트를 비활성화하는 것은 위험합니다. 대신 스핀락이나 뮤텍스를 사용해 커널 자료구조를 보호하고, 인터럽트는 활성화 상태로 유지하여 응답성을 확보하세요.
💡 디버깅 시에는 각 시스템 콜의 호출 횟수와 처리 시간을 측정하세요. 어떤 시스템 콜이 병목인지 파악하면 성능 최적화의 실마리를 찾을 수 있습니다.
3. 메모리 영역별 권한 설정
시작하며
여러분이 프로세스 메모리 레이아웃을 설계할 때 이런 딜레마를 겪어본 적 있나요? 코드 영역, 데이터 영역, 힙, 스택 모두 다른 보안 요구사항을 가지는데, 어떻게 각각에 맞는 권한을 설정할 것인가?
모든 영역을 쓰기 가능하게 하면 편하지만 보안이 취약하고, 너무 제한하면 프로그램이 작동하지 않습니다. 이런 문제는 실제 익스플로잇의 주요 공격 벡터가 됩니다.
코드 영역에 쓰기가 가능하면 공격자가 악성 코드를 주입할 수 있고, 데이터 영역에서 실행이 가능하면 버퍼 오버플로우로 쉘코드를 실행할 수 있습니다. W^X(Write XOR Execute) 원칙을 위반하면 치명적인 보안 홀이 생깁니다.
바로 이럴 때 필요한 것이 메모리 영역별 세분화된 권한 설정입니다. 각 영역의 용도에 맞게 읽기, 쓰기, 실행 권한을 정확히 설정하여 보안과 기능을 모두 달성할 수 있습니다.
개요
간단히 말해서, 메모리 영역별 권한 설정은 프로세스의 각 메모리 세그먼트(코드, 읽기 전용 데이터, 쓰기 가능 데이터, 힙, 스택)마다 적절한 페이지 테이블 플래그를 설정하여 최소 권한 원칙을 구현하는 것입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 현대 보안 메커니즘인 DEP(Data Execution Prevention)와 ASLR(Address Space Layout Randomization)이 제대로 작동하려면 메모리 영역의 권한이 명확히 구분되어야 합니다.
예를 들어, JIT 컴파일러를 사용하는 JavaScript 엔진이나 eBPF 같은 경우 코드를 동적으로 생성할 때, 생성 단계에서는 쓰기 가능하게 하고 실행 단계에서는 쓰기를 제거하는 방식으로 안전하게 처리합니다. 전통적인 방법과의 비교를 하자면, 기존에는 모든 메모리가 실행 가능했고 보안은 소프트웨어 검증에만 의존했다면, 이제는 하드웨어 NX 비트(No-Execute)를 활용해 데이터 영역에서 코드 실행을 원천 차단할 수 있습니다.
메모리 영역별 권한의 핵심 특징은 다음과 같습니다. 첫째, 코드 영역은 읽기+실행만 가능하게 하여 코드 변조를 방지합니다.
둘째, 상수 데이터(.rodata)는 읽기 전용으로 설정하여 보안 관련 상수가 변경되지 않도록 합니다. 셋째, 힙과 스택은 쓰기 가능하지만 실행 불가로 설정하여 버퍼 오버플로우 공격을 무력화합니다.
이러한 특징들이 Defense in Depth 전략의 핵심 계층을 형성합니다.
코드 예제
use x86_64::structures::paging::{Page, PageTableFlags, Mapper, Size4KiB};
use x86_64::VirtAddr;
pub struct MemoryRegion {
start: VirtAddr,
size: usize,
flags: PageTableFlags,
}
impl MemoryRegion {
/// 프로세스 메모리 영역 초기화 및 권한 설정
pub fn setup_process_memory(mapper: &mut impl Mapper<Size4KiB>) {
// 1. 코드 영역 (.text) - 읽기 + 실행, 쓰기 불가
let code_region = MemoryRegion {
start: VirtAddr::new(0x400000),
size: 0x100000, // 1MB
flags: PageTableFlags::PRESENT | PageTableFlags::USER_ACCESSIBLE,
// NO_EXECUTE 비트가 없으므로 실행 가능
};
Self::map_region(mapper, &code_region);
// 2. 읽기 전용 데이터 (.rodata) - 읽기만 가능
let rodata_region = MemoryRegion {
start: VirtAddr::new(0x500000),
size: 0x10000, // 64KB
flags: PageTableFlags::PRESENT
| PageTableFlags::USER_ACCESSIBLE
| PageTableFlags::NO_EXECUTE,
// WRITABLE 없음, NO_EXECUTE 설정
};
Self::map_region(mapper, &rodata_region);
// 3. 쓰기 가능 데이터 (.data, .bss) - 읽기 + 쓰기, 실행 불가
let data_region = MemoryRegion {
start: VirtAddr::new(0x600000),
size: 0x50000, // 320KB
flags: PageTableFlags::PRESENT
| PageTableFlags::WRITABLE
| PageTableFlags::USER_ACCESSIBLE
| PageTableFlags::NO_EXECUTE,
};
Self::map_region(mapper, &data_region);
// 4. 힙 (heap) - 읽기 + 쓰기, 실행 불가
let heap_region = MemoryRegion {
start: VirtAddr::new(0x700000),
size: 0x1000000, // 16MB
flags: PageTableFlags::PRESENT
| PageTableFlags::WRITABLE
| PageTableFlags::USER_ACCESSIBLE
| PageTableFlags::NO_EXECUTE,
};
Self::map_region(mapper, &heap_region);
// 5. 스택 (stack) - 읽기 + 쓰기, 실행 불가, 아래로 자람
let stack_region = MemoryRegion {
start: VirtAddr::new(0x7fff_ffff_f000), // 스택 최상단
size: 0x200000, // 2MB
flags: PageTableFlags::PRESENT
| PageTableFlags::WRITABLE
| PageTableFlags::USER_ACCESSIBLE
| PageTableFlags::NO_EXECUTE,
};
Self::map_region(mapper, &stack_region);
}
/// 메모리 영역을 페이지 단위로 매핑
fn map_region(mapper: &mut impl Mapper<Size4KiB>, region: &MemoryRegion) {
let start_page = Page::containing_address(region.start);
let end_page = Page::containing_address(region.start + region.size - 1u64);
for page in Page::range_inclusive(start_page, end_page) {
// 물리 메모리 할당 및 페이지 테이블 매핑
let frame = allocate_frame().expect("메모리 부족");
unsafe {
mapper.map_to(page, frame, region.flags, &mut *FRAME_ALLOCATOR)
.expect("페이지 매핑 실패")
.flush();
}
}
}
}
설명
이것이 하는 일: setup_process_memory 함수는 새 프로세스를 생성할 때 호출되어 표준 ELF 실행 파일의 메모리 레이아웃을 페이지 테이블에 매핑합니다. 각 영역마다 용도에 맞는 권한을 설정하여 보안과 기능을 동시에 확보합니다.
코드를 단계별로 나누어 설명하겠습니다. 첫 번째로, 코드 영역(.text)을 설정합니다.
0x400000 주소에서 시작하는 1MB 영역을 PRESENT와 USER_ACCESSIBLE 플래그로 매핑합니다. 중요한 점은 NO_EXECUTE 비트를 설정하지 않았다는 것인데, x86-64에서는 NX 비트가 없으면 실행 가능하다는 의미입니다.
동시에 WRITABLE 플래그도 없으므로 읽기와 실행만 가능한 영역이 됩니다. 이렇게 하면 공격자가 버퍼 오버플로우를 통해 코드를 수정하는 것을 하드웨어 레벨에서 차단할 수 있습니다.
그 다음으로, 읽기 전용 데이터 영역(.rodata)이 처리됩니다. 문자열 상수, 점프 테이블, vtable 같은 변경되면 안 되는 데이터가 여기에 위치합니다.
WRITABLE 플래그를 제거하고 NO_EXECUTE를 추가하여 읽기만 가능하게 만듭니다. 예를 들어 암호화 키나 권한 검사 문자열 같은 보안 관련 상수가 런타임에 변조되는 것을 방지할 수 있습니다.
세 번째 단계에서는 쓰기 가능 데이터(.data, .bss)와 힙을 설정합니다. 전역 변수와 동적 할당 메모리는 당연히 쓰기가 필요하므로 WRITABLE 플래그를 설정하지만, NO_EXECUTE를 함께 설정하여 실행은 불가능하게 합니다.
이것이 바로 DEP(Data Execution Prevention)의 핵심입니다. 공격자가 힙에 쉘코드를 올려도 실행하려는 순간 CPU가 페이지 폴트를 발생시킵니다.
마지막으로, map_region 함수가 실제 페이지 매핑을 수행합니다. 영역의 시작 주소와 끝 주소를 페이지 경계로 정렬한 뒤, 모든 페이지를 순회하면서 물리 메모리 프레임을 할당하고 지정된 플래그로 매핑합니다.
flush() 호출은 TLB(Translation Lookaside Buffer)를 갱신하여 변경사항이 즉시 반영되도록 합니다. 여러분이 이 코드를 사용하면 ROP(Return-Oriented Programming)이나 JOP(Jump-Oriented Programming) 같은 고급 익스플로잇 기법에 대한 방어력을 크게 높일 수 있습니다.
코드 영역이 읽기 전용이므로 ROP 가젯을 임의로 추가할 수 없고, 데이터 영역이 실행 불가이므로 쉘코드를 직접 실행할 수도 없습니다. 또한 커널 메모리 영역에는 USER_ACCESSIBLE 플래그를 제거하여 사용자 모드에서 접근 자체를 차단할 수 있습니다.
실전 팁
💡 JIT 컴파일러를 구현할 때는 W^X를 유지하세요. 코드 생성 시에는 쓰기 가능하게 매핑하고, 생성 완료 후 mprotect 시스템 콜(또는 페이지 테이블 직접 수정)로 쓰기를 제거하고 실행을 허용합니다.
💡 스택에 가드 페이지(권한이 전혀 없는 페이지)를 추가하세요. 스택 오버플로우가 발생하면 즉시 페이지 폴트가 발생하여 조용한 메모리 손상 대신 명확한 오류를 얻을 수 있습니다.
💡 ASLR을 구현할 때는 각 영역의 시작 주소를 랜덤화하되, 영역 간 충돌이 없도록 충분한 간격을 유지하세요. 64비트에서는 주소 공간이 넓으니 각 영역 사이에 최소 수 GB씩 띄우는 것을 권장합니다.
💡 공유 라이브러리(.so)는 각 프로세스마다 다른 가상 주소에 매핑되지만 동일한 물리 메모리를 참조하도록 설정하세요. 이렇게 하면 메모리를 절약하면서도 프로세스 격리를 유지할 수 있습니다.
💡 페이지 폴트 핸들러에서 권한 위반을 로깅하세요. 어떤 주소에 어떤 접근을 시도했는지 기록하면 공격 시도를 탐지하거나 버그를 찾는 데 유용합니다.
4. 커널 메모리 보호와 격리
시작하며
여러분이 운영체제를 개발하면서 이런 공포를 느껴본 적 있나요? 사용자 프로그램의 버그가 커널 메모리를 덮어써서 시스템 전체가 크래시되는 상황.
또는 악의적인 프로그램이 커널 자료구조를 직접 수정해서 권한을 상승시키는 경우. 이런 문제는 운영체제 보안의 가장 기본적이면서도 중요한 과제입니다.
커널은 모든 하드웨어와 자원을 제어하므로, 커널이 침해되면 시스템의 모든 보안 메커니즘이 무력화됩니다. 실제로 많은 권한 상승 취약점이 커널 메모리 보호 우회에서 비롯됩니다.
바로 이럴 때 필요한 것이 커널 메모리 격리(Kernel Memory Isolation)입니다. 사용자 공간과 커널 공간을 명확히 분리하고, 사용자 모드에서는 커널 메모리에 절대 접근할 수 없도록 하드웨어 레벨에서 보호합니다.
개요
간단히 말해서, 커널 메모리 격리는 가상 주소 공간을 상위 영역(커널)과 하위 영역(사용자)으로 나누고, USER_ACCESSIBLE 플래그를 통해 사용자 모드에서 커널 영역 접근을 차단하는 메커니즘입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 커널과 사용자 프로세스가 같은 페이지 테이블을 공유하면서도 서로를 침범하지 못하도록 해야 성능과 보안을 모두 달성할 수 있습니다.
예를 들어, 시스템 콜 처리 중에 페이지 테이블을 완전히 교체하지 않고도 커널 코드를 실행할 수 있어 컨텍스트 스위칭 오버헤드를 크게 줄일 수 있습니다. 전통적인 방법과의 비교를 하자면, 기존에는 커널과 사용자 공간이 별도의 페이지 테이블을 사용해 완전히 분리되었다면, 이제는 Higher Half Kernel 방식으로 같은 페이지 테이블에 공존하되 권한으로 격리합니다.
하지만 Meltdown 취약점 이후로는 KPTI(Kernel Page Table Isolation)를 적용해 다시 분리하는 추세입니다. 커널 메모리 격리의 핵심 특징은 다음과 같습니다.
첫째, 커널 영역은 가상 주소 0xffff800000000000 이상의 높은 주소에 매핑하여 사용자 영역과 물리적으로 분리합니다. 둘째, 모든 커널 페이지에서 USER_ACCESSIBLE 플래그를 제거하여 CPU가 자동으로 접근을 차단하도록 합니다.
셋째, 사용자 포인터를 역참조하기 전에 주소 범위를 검증하여 커널 주소를 가리키는 포인터를 거부합니다. 이러한 특징들이 사용자 프로그램이 어떤 방법으로도 커널 메모리를 읽거나 쓸 수 없도록 보장합니다.
코드 예제
use x86_64::structures::paging::{PageTable, PageTableFlags, PhysFrame};
use x86_64::{VirtAddr, PhysAddr};
/// 커널 메모리 영역 시작 주소 (Higher Half Kernel)
pub const KERNEL_BASE: u64 = 0xffff_8000_0000_0000;
pub struct KernelMemoryManager;
impl KernelMemoryManager {
/// 커널 페이지 테이블 초기화 - 사용자 접근 완전 차단
pub fn init_kernel_page_table() -> PageTable {
let mut page_table = PageTable::new();
// 1. 커널 코드 영역 매핑 (.text) - 커널 전용, 읽기+실행
Self::map_kernel_region(
&mut page_table,
VirtAddr::new(KERNEL_BASE),
PhysAddr::new(0x100000), // 물리 주소 1MB 시작
0x200000, // 2MB 크기
PageTableFlags::PRESENT | PageTableFlags::GLOBAL,
// USER_ACCESSIBLE 없음! 사용자 모드 접근 불가
);
// 2. 커널 데이터 영역 (.data, .bss) - 커널 전용, 읽기+쓰기
Self::map_kernel_region(
&mut page_table,
VirtAddr::new(KERNEL_BASE + 0x200000),
PhysAddr::new(0x300000),
0x100000, // 1MB
PageTableFlags::PRESENT
| PageTableFlags::WRITABLE
| PageTableFlags::NO_EXECUTE
| PageTableFlags::GLOBAL,
);
// 3. 커널 힙 (동적 할당) - 커널 전용, 읽기+쓰기
Self::map_kernel_region(
&mut page_table,
VirtAddr::new(KERNEL_BASE + 0x10000000),
PhysAddr::new(0x400000),
0x10000000, // 256MB
PageTableFlags::PRESENT
| PageTableFlags::WRITABLE
| PageTableFlags::NO_EXECUTE
| PageTableFlags::GLOBAL,
);
page_table
}
/// 사용자 포인터 안전성 검증 - 커널 주소 접근 시도 차단
pub fn validate_user_pointer(ptr: VirtAddr, size: usize) -> Result<(), MemoryError> {
let end_addr = ptr + size;
// 커널 영역인지 확인
if ptr.as_u64() >= KERNEL_BASE || end_addr.as_u64() >= KERNEL_BASE {
return Err(MemoryError::KernelAddressAccess);
}
// 페이지가 실제로 매핑되어 있고 USER_ACCESSIBLE인지 확인
let page = Page::containing_address(ptr);
let entry = get_page_table_entry(page)?;
if !entry.flags().contains(PageTableFlags::USER_ACCESSIBLE) {
return Err(MemoryError::PermissionDenied);
}
Ok(())
}
/// 사용자 공간에서 데이터를 안전하게 복사
pub unsafe fn copy_from_user<T>(user_ptr: VirtAddr) -> Result<T, MemoryError> {
// 1. 주소 검증
Self::validate_user_pointer(user_ptr, core::mem::size_of::<T>())?;
// 2. 안전한 복사 (페이지 폴트 핸들링 포함)
let value = core::ptr::read_volatile(user_ptr.as_ptr::<T>());
Ok(value)
}
}
설명
이것이 하는 일: KernelMemoryManager는 운영체제 부팅 시 커널 메모리를 초기화하고, 런타임에 사용자 포인터의 안전성을 검증하는 역할을 합니다. 핵심은 커널 페이지에 USER_ACCESSIBLE 플래그를 설정하지 않아 CPU가 하드웨어 레벨에서 접근을 거부하도록 만드는 것입니다.
코드를 단계별로 나누어 설명하겠습니다. 첫 번째로, init_kernel_page_table에서 커널 메모리를 Higher Half 방식으로 매핑합니다.
0xffff800000000000 이상의 가상 주소는 관례적으로 커널 전용으로 예약되어 있으며, 이 주소는 사용자 프로그램이 일반적인 포인터 연산으로 도달할 수 없을 정도로 높습니다. 커널 코드는 PRESENT와 GLOBAL 플래그만 설정하여 읽기+실행 가능하게 하되, WRITABLE을 제거해 코드 변조를 막습니다.
GLOBAL 플래그는 TLB에서 이 페이지를 제거하지 않아 컨텍스트 스위칭 시에도 캐시를 유지하게 합니다. 그 다음으로, validate_user_pointer 함수가 시스템 콜에서 사용자가 전달한 포인터를 검증합니다.
공격자가 시스템 콜의 인자로 커널 메모리 주소를 전달해서 커널이 자신의 메모리를 읽거나 쓰도록 속이는 것을 방지합니다. 먼저 주소가 KERNEL_BASE보다 낮은지 확인하고, 그 다음 실제 페이지 테이블 엔트리를 조회해 USER_ACCESSIBLE 플래그가 설정되어 있는지 확인합니다.
두 단계 검증을 통해 더 안전합니다. 세 번째 단계에서는 copy_from_user가 검증된 사용자 포인터에서 데이터를 안전하게 복사합니다.
read_volatile을 사용하는 이유는 컴파일러 최적화를 방지하고, 실제 메모리 접근이 발생하도록 보장하기 위함입니다. 만약 사용자가 전달한 주소가 실제로는 매핑되지 않은 페이지라면 페이지 폴트가 발생하는데, 커널의 페이지 폴트 핸들러는 이를 감지하고 EFAULT 오류를 반환합니다.
마지막으로, GLOBAL 플래그의 의미를 이해하는 것이 중요합니다. 일반적으로 컨텍스트 스위칭 시 CR3 레지스터를 변경하면 TLB가 플러시되지만, GLOBAL 플래그가 설정된 페이지는 TLB에 남아있습니다.
커널 메모리는 모든 프로세스에서 동일한 매핑을 사용하므로, TLB에 캐시해두면 시스템 콜 처리 성능이 크게 향상됩니다. 여러분이 이 코드를 사용하면 Meltdown 이전의 전통적인 커널 보호를 구현할 수 있습니다.
사용자 프로그램이 커널 주소를 역참조하려고 하면 CPU가 즉시 페이지 폴트(Page Fault, 오류 코드에 User bit 설정)를 발생시켜 프로그램을 종료시킵니다. 또한 시스템 콜에서 모든 사용자 포인터를 검증하므로, 커널이 실수로 커널 메모리를 사용자에게 노출하는 것도 방지할 수 있습니다.
실전 팁
💡 Meltdown 취약점을 방어하려면 KPTI(Kernel Page Table Isolation)를 구현하세요. 사용자 모드 실행 시에는 커널 페이지를 완전히 언매핑하고, 시스템 콜 진입 시에만 커널 페이지를 매핑합니다. 성능 오버헤드가 있지만 필수적입니다.
💡 copy_from_user와 copy_to_user는 반드시 페이지 폴트를 처리할 수 있어야 합니다. 사용자 페이지가 스왑 아웃되어 있을 수 있으므로, 페이지 폴트 핸들러는 fixup 테이블을 확인해 커널 코드에서 발생한 폴트인지 판단해야 합니다.
💡 커널 스택과 사용자 스택을 엄격히 분리하세요. 시스템 콜 진입 시 TSS에 설정된 커널 스택으로 자동 전환되며, 각 CPU 코어마다 별도의 커널 스택이 필요합니다. 스택이 겹치면 정보 누출이 발생할 수 있습니다.
💡 SMEP(Supervisor Mode Execution Prevention)을 활성화하세요. CR4 레지스터의 SMEP 비트를 설정하면 커널 모드에서 사용자 페이지의 코드를 실행할 수 없게 되어, ret2usr 공격을 방어할 수 있습니다.
💡 디버깅 시에는 /proc/kallsyms 같은 커널 심볼 정보를 제공하되, 프로덕션 환경에서는 비활성화하세요. KASLR(Kernel Address Space Layout Randomization)과 함께 사용하면 공격자가 커널 함수 주소를 알아내기 어렵게 만들 수 있습니다.
5. 동적 권한 변경과 mprotect
시작하며
여러분이 JIT 컴파일러나 동적 코드 생성기를 구현할 때 이런 모순에 빠진 적 있나요? 코드를 생성하려면 메모리에 쓰기가 필요한데, 실행하려면 쓰기를 금지해야 하는 상황.
W^X 원칙을 지키면서도 동적 코드 생성을 어떻게 가능하게 할 것인가? 이런 문제는 현대 프로그래밍 언어의 런타임(JavaScript V8, Python PyPy, JVM 등)에서 필수적으로 해결해야 하는 과제입니다.
성능을 위해 JIT 컴파일이 필요하지만, 보안을 위해 코드 영역은 쓰기 불가여야 합니다. 이 두 요구사항이 충돌합니다.
바로 이럴 때 필요한 것이 동적 권한 변경(Dynamic Permission Change) 메커니즘입니다. mprotect 시스템 콜을 통해 런타임에 페이지 권한을 변경하여 필요할 때만 쓰기를 허용하고, 완료 후 즉시 쓰기를 제거할 수 있습니다.
개요
간단히 말해서, 동적 권한 변경은 이미 매핑된 메모리 영역의 페이지 테이블 플래그를 런타임에 수정하여 접근 권한을 변경하는 기능입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 정적으로 모든 메모리 권한을 결정할 수 없는 경우가 많습니다.
예를 들어, eBPF 프로그램을 로드할 때는 먼저 바이트코드를 쓰기 가능한 메모리에 복사하고, 검증을 거친 후 JIT 컴파일하여 네이티브 코드로 변환하고, 마지막에 실행 가능/쓰기 불가로 권한을 변경하는 다단계 프로세스가 필요합니다. 전통적인 방법과의 비교를 하자면, 기존에는 코드 생성 영역을 처음부터 RWX(읽기+쓰기+실행)로 설정해 보안 위험이 있었다면, 이제는 RW -> RX로 전환하는 방식으로 W^X 원칙을 유지하면서도 동적 코드 생성이 가능합니다.
동적 권한 변경의 핵심 특징은 다음과 같습니다. 첫째, 페이지 단위로만 권한을 변경할 수 있으므로 변경하려는 영역은 페이지 경계에 정렬되어야 합니다.
둘째, 권한 변경 후 TLB를 플러시해야 CPU 캐시에 반영됩니다. 셋째, 멀티스레드 환경에서는 다른 스레드가 해당 메모리에 접근 중일 수 있으므로 동기화가 필요합니다.
이러한 특징들이 안전하고 효율적인 동적 코드 생성을 가능하게 합니다.
코드 예제
use x86_64::structures::paging::{Page, PageTableFlags, Mapper, Size4KiB};
use x86_64::VirtAddr;
pub struct JitCodeAllocator {
code_region: VirtAddr,
size: usize,
}
impl JitCodeAllocator {
/// JIT 코드 영역 할당 (초기에는 쓰기 가능)
pub fn new(size: usize) -> Result<Self, MemoryError> {
let code_region = allocate_user_memory(size)?;
// 1. 초기에는 RW (읽기+쓰기) 권한으로 할당
let flags = PageTableFlags::PRESENT
| PageTableFlags::WRITABLE
| PageTableFlags::USER_ACCESSIBLE
| PageTableFlags::NO_EXECUTE;
map_memory_region(code_region, size, flags)?;
Ok(JitCodeAllocator { code_region, size })
}
/// 네이티브 코드 생성 (쓰기 가능 상태에서만 가능)
pub fn emit_code(&mut self, bytecode: &[u8]) -> Result<(), JitError> {
// 2. 바이트코드를 네이티브 코드로 컴파일
let native_code = self.compile_to_native(bytecode)?;
// 3. 코드 영역에 쓰기 (현재 쓰기 가능 상태)
unsafe {
let dest = self.code_region.as_mut_ptr::<u8>();
core::ptr::copy_nonoverlapping(
native_code.as_ptr(),
dest,
native_code.len()
);
}
Ok(())
}
/// 코드 생성 완료 후 실행 가능하도록 권한 변경
pub fn make_executable(&self) -> Result<(), MemoryError> {
// 4. RW -> RX 전환 (쓰기 제거, 실행 추가)
let new_flags = PageTableFlags::PRESENT
| PageTableFlags::USER_ACCESSIBLE;
// WRITABLE 제거됨, NO_EXECUTE 제거됨 (실행 가능)
Self::change_protection(self.code_region, self.size, new_flags)?;
Ok(())
}
/// 코드 수정을 위해 다시 쓰기 가능하도록 변경
pub fn make_writable(&self) -> Result<(), MemoryError> {
// 5. RX -> RW 전환 (실행 제거, 쓰기 추가)
let new_flags = PageTableFlags::PRESENT
| PageTableFlags::WRITABLE
| PageTableFlags::USER_ACCESSIBLE
| PageTableFlags::NO_EXECUTE;
Self::change_protection(self.code_region, self.size, new_flags)?;
Ok(())
}
/// mprotect 시스템 콜 구현 - 페이지 권한 변경
fn change_protection(addr: VirtAddr, size: usize, flags: PageTableFlags)
-> Result<(), MemoryError>
{
// 6. 페이지 경계로 정렬
let start_page = Page::containing_address(addr);
let end_page = Page::containing_address(addr + size - 1u64);
// 7. 각 페이지의 플래그 업데이트
let mapper = get_current_page_table_mapper();
for page in Page::range_inclusive(start_page, end_page) {
let entry = mapper.translate_page(page)?;
// 기존 물리 프레임 유지, 플래그만 변경
unsafe {
mapper.update_flags(page, flags)?;
}
}
// 8. TLB 플러시 - 변경사항 즉시 반영
for page in Page::range_inclusive(start_page, end_page) {
flush_tlb(page);
}
Ok(())
}
/// 컴파일된 코드 실행
pub unsafe fn execute(&self) -> i32 {
// 9. 함수 포인터로 변환하여 호출
let func: extern "C" fn() -> i32 = core::mem::transmute(self.code_region);
func()
}
}
설명
이것이 하는 일: JitCodeAllocator는 동적으로 생성된 네이티브 코드를 안전하게 관리합니다. 코드 생성 시에는 쓰기 가능 상태로 유지하다가, 생성이 완료되면 쓰기를 제거하고 실행을 허용하는 방식으로 W^X 원칙을 준수합니다.
코드를 단계별로 나누어 설명하겠습니다. 첫 번째로, new 메서드에서 JIT 코드를 위한 메모리 영역을 할당합니다.
초기에는 WRITABLE | NO_EXECUTE 플래그로 설정하여 쓰기는 가능하지만 실행은 불가능하게 만듭니다. 이 시점에는 아직 코드가 생성되지 않았으므로 실행할 필요가 없고, 대신 바이트코드를 복사하고 컴파일할 준비를 합니다.
그 다음으로, emit_code 메서드가 실제 네이티브 코드를 생성합니다. compile_to_native 함수는 바이트코드를 x86-64 기계어로 변환하고, copy_nonoverlapping으로 코드 영역에 복사합니다.
이 작업은 메모리가 쓰기 가능한 상태이므로 안전하게 수행됩니다. 만약 이 시점에 실행 가능했다면 공격자가 버퍼 오버플로우를 통해 악의적인 코드를 주입할 수 있지만, NO_EXECUTE가 설정되어 있어 보호됩니다.
세 번째 단계에서는 make_executable이 권한을 변경합니다. change_protection 함수를 호출하여 WRITABLE 플래그를 제거하고 NO_EXECUTE도 제거합니다(x86-64에서는 NX 비트가 없으면 실행 가능).
이제 이 메모리는 읽기+실행만 가능한 코드 영역이 됩니다. 중요한 점은 change_protection이 실제 페이지 테이블 엔트리를 직접 수정한다는 것입니다.
네 번째 단계에서는 change_protection의 내부 동작을 살펴봅니다. 먼저 주소를 페이지 경계로 정렬하는데, 페이지 테이블은 4KB 단위로 관리되므로 중간 주소를 지정할 수 없습니다.
Page::range_inclusive로 영향받는 모든 페이지를 순회하면서 각 페이지의 플래그를 업데이트합니다. update_flags는 물리 프레임 주소는 유지하고 플래그만 변경합니다.
마지막으로, TLB 플러시가 매우 중요합니다. CPU는 가상 주소에서 물리 주소로의 변환 결과를 TLB에 캐시하는데, 페이지 테이블을 변경해도 TLB는 자동으로 갱신되지 않습니다.
flush_tlb를 호출하여 해당 페이지의 TLB 엔트리를 무효화해야 다음 접근 시 새로운 권한이 적용됩니다. 이를 빼먹으면 권한 변경이 즉시 반영되지 않아 보안 문제가 생길 수 있습니다.
여러분이 이 코드를 사용하면 V8, LuaJIT, PyPy 같은 고성능 JIT 컴파일러를 안전하게 구현할 수 있습니다. 코드 생성 중에는 실행이 불가능하여 공격자가 부분적으로 생성된 코드를 악용할 수 없고, 실행 중에는 쓰기가 불가능하여 코드 변조를 막을 수 있습니다.
또한 make_writable로 필요할 때 다시 쓰기 가능 상태로 되돌릴 수 있어 코드 패칭이나 인라인 캐싱 같은 최적화 기법도 적용 가능합니다.
실전 팁
💡 권한 변경은 비용이 큰 작업이므로 가능한 한 배치 처리하세요. 여러 함수를 컴파일한 후 한 번에 make_executable을 호출하는 것이 효율적입니다.
💡 멀티스레드 환경에서는 코드 영역을 뮤텍스로 보호하세요. 한 스레드가 권한을 변경하는 동안 다른 스레드가 해당 코드를 실행하면 race condition이 발생할 수 있습니다.
💡 일부 시스템에서는 RWX를 완전히 금지하는 보안 정책(SELinux, PaX 등)이 있습니다. 이런 환경에서는 더블 매핑 기법을 사용하세요. 동일한 물리 메모리를 두 개의 가상 주소로 매핑하되, 하나는 RW, 다른 하나는 RX로 설정합니다.
💡 TLB 플러시는 성능에 영향을 줍니다. invlpg 명령어는 단일 페이지만 플러시하므로, 전체 TLB 플러시(CR3 재로드)보다 빠릅니다. 변경된 페이지 수가 적으면 invlpg를 사용하세요.
💡 디버깅 시에는 페이지 폴트 핸들러에서 권한 위반 유형을 구분하세요. "쓰기 시도" vs "실행 시도" vs "읽기 시도"를 구분하면 어떤 권한 설정이 잘못되었는지 빠르게 파악할 수 있습니다.
6. 페이지 폴트 핸들러와 권한 검증
시작하며
여러분이 메모리 접근 오류를 디버깅할 때 이런 답답함을 느껴본 적 있나요? "Segmentation Fault"라는 메시지만 보고 정확히 어떤 메모리 위반이 발생했는지, 읽기인지 쓰기인지, 권한 문제인지 매핑 문제인지 알 수 없는 상황.
이런 문제는 개발 생산성과 보안에 모두 영향을 줍니다. 명확한 오류 정보가 없으면 디버깅에 시간이 오래 걸리고, 보안 공격 시도를 탐지하고 분석하기도 어렵습니다.
또한 페이지 폴트는 단순한 오류가 아니라 demand paging, copy-on-write 같은 고급 메모리 관리 기법의 핵심 메커니즘이기도 합니다. 바로 이럴 때 필요한 것이 정교한 페이지 폴트 핸들러(Page Fault Handler)입니다.
CPU가 메모리 접근 위반을 감지했을 때 호출되는 이 핸들러는 오류 원인을 분석하고, 복구 가능한 경우 처리하며, 그렇지 않으면 상세한 정보를 제공합니다.
개요
간단히 말해서, 페이지 폴트 핸들러는 CPU가 메모리 접근 권한 위반이나 페이지 부재를 감지했을 때 자동으로 호출되는 인터럽트 핸들러로, 오류 코드를 분석하여 적절한 조치를 취합니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 페이지 폴트는 단순한 오류 처리를 넘어 현대 운영체제의 핵심 기능을 구현하는 메커니즘입니다.
예를 들어, Copy-on-Write(COW) 최적화는 fork() 시 부모와 자식이 메모리를 공유하다가 쓰기 시도 시 페이지 폴트를 발생시켜 그때 복사하는 방식으로 작동합니다. 또한 swap 시스템은 페이지 폴트를 통해 디스크에서 메모리로 페이지를 로드합니다.
전통적인 방법과의 비교를 하자면, 기존에는 모든 메모리를 미리 할당했다면, 이제는 페이지 폴트를 활용해 지연 할당(lazy allocation)을 수행하여 메모리 효율을 크게 높일 수 있습니다. 페이지 폴트 핸들러의 핵심 특징은 다음과 같습니다.
첫째, 오류 코드에서 접근 유형(읽기/쓰기/실행)과 권한 상태를 파악할 수 있습니다. 둘째, CR2 레지스터에서 폴트를 발생시킨 정확한 가상 주소를 얻을 수 있습니다.
셋째, 사용자 모드와 커널 모드의 폴트를 구분하여 다르게 처리할 수 있습니다. 넷째, 복구 가능한 폴트(demand paging 등)는 처리 후 명령어를 재실행하고, 복구 불가능한 폴트는 프로세스를 종료시킵니다.
이러한 특징들이 안전하고 효율적인 메모리 관리를 가능하게 합니다.
코드 예제
use x86_64::structures::idt::{InterruptDescriptorTable, PageFaultErrorCode};
use x86_64::registers::control::Cr2;
use x86_64::VirtAddr;
/// 페이지 폴트 인터럽트 핸들러
pub extern "x86-interrupt" fn page_fault_handler(
stack_frame: InterruptStackFrame,
error_code: PageFaultErrorCode,
) {
// 1. 폴트를 발생시킨 가상 주소 읽기
let faulting_address = Cr2::read();
// 2. 오류 코드 분석
let is_present = !error_code.contains(PageFaultErrorCode::PROTECTION_VIOLATION);
let is_write = error_code.contains(PageFaultErrorCode::CAUSED_BY_WRITE);
let is_user = error_code.contains(PageFaultErrorCode::USER_MODE);
let is_instruction_fetch = error_code.contains(PageFaultErrorCode::INSTRUCTION_FETCH);
// 3. 폴트 유형별 처리
if !is_present {
// 페이지가 매핑되지 않음 - Demand Paging 처리
if handle_demand_paging(faulting_address).is_ok() {
return; // 복구 성공, 명령어 재실행
}
} else if is_write {
// 쓰기 권한 위반 - Copy-on-Write 확인
if handle_copy_on_write(faulting_address).is_ok() {
return; // COW 처리 완료
}
} else if is_instruction_fetch {
// 실행 권한 위반 - NX 비트 설정된 페이지에서 실행 시도
log_security_violation(
"실행 권한 위반",
faulting_address,
get_current_process()
);
}
// 4. 복구 불가능한 폴트 - 상세 정보 출력 및 종료
panic!(
"페이지 폴트: {:?}\n\
주소: {:#x}\n\
오류: {}\n\
모드: {}\n\
스택 프레임:\n{:#?}",
if !is_present { "페이지 부재" }
else if is_write { "쓰기 권한 위반" }
else if is_instruction_fetch { "실행 권한 위반" }
else { "읽기 권한 위반" },
faulting_address,
error_code,
if is_user { "사용자 모드" } else { "커널 모드" },
stack_frame
);
}
/// Demand Paging 처리 - 필요할 때 페이지 할당
fn handle_demand_paging(addr: VirtAddr) -> Result<(), PageFaultError> {
let process = get_current_process();
// 주소가 프로세스의 유효한 메모리 영역인지 확인
let vma = process.find_vma(addr)?;
// 물리 프레임 할당
let frame = allocate_frame()?;
// 페이지 테이블에 매핑 (VMA의 권한 사용)
let page = Page::containing_address(addr);
unsafe {
process.page_table.map_to(
page,
frame,
vma.flags, // 힙/스택 등 영역별 권한
&mut *FRAME_ALLOCATOR
)?.flush();
}
Ok(())
}
/// Copy-on-Write 처리 - 공유 페이지 쓰기 시 복사
fn handle_copy_on_write(addr: VirtAddr) -> Result<(), PageFaultError> {
let page = Page::containing_address(addr);
let process = get_current_process();
// COW 페이지인지 확인 (읽기 전용 + COW 플래그)
if !process.is_cow_page(page) {
return Err(PageFaultError::NotCowPage);
}
// 원본 페이지 내용 복사
let old_frame = process.page_table.translate_page(page)?;
let new_frame = allocate_frame()?;
unsafe {
copy_page(old_frame, new_frame);
}
// 쓰기 가능으로 재매핑
let mut flags = process.get_page_flags(page);
flags.insert(PageTableFlags::WRITABLE);
unsafe {
process.page_table.update_flags(page, flags)?.flush();
}
Ok(())
}
설명
이것이 하는 일: page_fault_handler는 CPU가 메모리 접근 권한 위반이나 페이지 부재를 감지했을 때 IDT(Interrupt Descriptor Table)를 통해 자동으로 호출됩니다. 이 핸들러는 폴트의 원인을 분석하고, 복구 가능한 경우(demand paging, COW 등) 처리한 후 원래 명령어를 재실행하거나, 복구 불가능한 경우 프로세스를 종료시킵니다.
코드를 단계별로 나누어 설명하겠습니다. 첫 번째로, Cr2::read()로 폴트를 발생시킨 정확한 가상 주소를 읽습니다.
CR2는 CPU가 자동으로 설정하는 특수 레지스터로, 페이지 폴트가 발생한 순간의 메모리 주소를 담고 있습니다. 이 정보가 없으면 어떤 메모리 접근이 문제인지 알 수 없습니다.
그 다음으로, error_code를 분석하여 폴트의 세부 사항을 파악합니다. PROTECTION_VIOLATION 비트가 없으면 페이지가 아예 매핑되지 않은 것이고(PRESENT=0), 있으면 페이지는 존재하지만 권한이 부족한 것입니다.
CAUSED_BY_WRITE는 쓰기 시도로 인한 폴트, INSTRUCTION_FETCH는 실행 시도(NX 비트 위반), USER_MODE는 사용자 모드에서 발생한 폴트를 의미합니다. 이 비트들의 조합으로 정확한 원인을 파악할 수 있습니다.
세 번째 단계에서는 복구 가능한 폴트를 처리합니다. 페이지가 매핑되지 않았다면(is_present=false) handle_demand_paging을 호출합니다.
이 함수는 주소가 프로세스의 유효한 VMA(Virtual Memory Area, 힙이나 스택 같은 영역) 내에 있는지 확인하고, 있다면 물리 메모리를 할당하여 매핑합니다. 예를 들어 malloc으로 힙을 확장할 때 실제로는 페이지를 매핑하지 않다가, 첫 접근 시 페이지 폴트로 할당하는 lazy allocation 방식입니다.
네 번째 단계는 Copy-on-Write 처리입니다. 쓰기 시도로 폴트가 발생했고(is_write=true), 해당 페이지가 COW로 표시되어 있다면, 부모와 공유하던 페이지를 복사하여 새로운 물리 프레임에 매핑하고 WRITABLE 플래그를 추가합니다.
fork() 후 자식 프로세스가 메모리를 수정할 때까지 부모와 메모리를 공유하여 효율을 높이는 기법입니다. 마지막으로, 복구할 수 없는 폴트는 상세한 정보와 함께 프로세스를 종료합니다.
panic! 매크로는 폴트 유형(페이지 부재/쓰기 위반/실행 위반/읽기 위반), 주소, 오류 코드, 발생 모드(사용자/커널), 스택 프레임(함수 호출 기록)을 모두 출력하여 디버깅을 돕습니다.
특히 실행 권한 위반은 보안 공격 시도일 가능성이 높으므로 별도로 로깅합니다. 여러분이 이 코드를 사용하면 메모리 효율과 보안을 동시에 달성할 수 있습니다.
Demand paging으로 실제 사용하는 메모리만 할당하여 효율을 높이고, COW로 fork 성능을 극대화하며, 권한 위반은 명확한 오류 메시지로 디버깅을 쉽게 만들 수 있습니다. 또한 보안 공격 시도(코드 인젝션, 버퍼 오버플로우 등)를 실시간으로 탐지하고 로깅할 수 있습니다.
실전 팁
💡 페이지 폴트 핸들러 내부에서 절대로 페이지 폴트를 발생시키지 마세요. 이중 폴트(Double Fault)로 이어져 시스템이 크래시됩니다. 핸들러가 사용하는 모든 데이터는 미리 매핑되어 있어야 합니다.
💡 커널 모드에서 발생한 폴트는 특별히 주의하세요. 사용자 포인터를 검증 없이 역참조했을 가능성이 높으므로, fixup 테이블을 확인해 copy_from_user 같은 함수에서 발생한 폴트인지 판단합니다.
💡 성능을 위해 TLB shootdown을 최적화하세요. 멀티코어에서 한 CPU가 페이지를 매핑하면 다른 CPU의 TLB도 무효화해야 하는데, 이를 IPI(Inter-Processor Interrupt)로 처리합니다.
💡 스택 오버플로우를 감지하려면 스택 아래에 가드 페이지를 두세요. PRESENT 비트를 제거한 페이지를 배치하면 스택이 확장되면서 즉시 페이지 폴트가 발생합니다.
💡 페이지 폴트 통계를 수집하세요. 어떤 주소에서 폴트가 자주 발생하는지, 어떤 유형이 많은지 분석하면 메모리 접근 패턴을 파악하고 최적화할 수 있습니다.
7. SMAP과 SMEP 하드웨어 보호
시작하며
여러분이 커널 취약점 공격을 방어하려 할 때 이런 한계를 느껴본 적 있나요? 소프트웨어로 모든 사용자 포인터를 검증하려 하지만, 실수로 하나라도 빠뜨리면 공격자가 커널 권한을 탈취하는 상황.
또는 커널 버그를 이용해 사용자 공간의 악성 코드를 커널 모드에서 실행하는 ret2usr 공격. 이런 문제는 소프트웨어 검증만으로는 완벽히 방어하기 어렵습니다.
커널 코드가 수십만 줄이 넘는 상황에서 모든 사용자 포인터 접근을 검증하는 것은 현실적으로 불가능하고, 하나의 실수가 전체 시스템을 위험에 빠뜨립니다. Defense in Depth를 위해서는 하드웨어 레벨의 추가 보호가 필요합니다.
바로 이럴 때 필요한 것이 SMAP(Supervisor Mode Access Prevention)과 SMEP(Supervisor Mode Execution Prevention)입니다. CPU 레벨에서 커널 모드가 사용자 메모리에 접근하거나 실행하는 것을 원천 차단하여 커널 취약점 공격을 무력화합니다.
개요
간단히 말해서, SMAP은 커널 모드에서 사용자 페이지(USER_ACCESSIBLE 플래그 설정)에 읽기/쓰기하는 것을 차단하고, SMEP은 커널 모드에서 사용자 페이지의 코드를 실행하는 것을 차단하는 CPU 보안 기능입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 커널 취약점을 악용하는 대부분의 공격은 커널이 사용자 메모리에 접근하도록 속이는 방식입니다.
예를 들어, 시스템 콜 핸들러에서 사용자 포인터 검증을 빠뜨린 경우, 공격자는 커널 자료구조를 가리키는 포인터를 전달해 커널이 자신의 메모리를 수정하도록 만들 수 있습니다. SMAP이 활성화되면 이런 공격이 즉시 페이지 폴트를 발생시켜 차단됩니다.
전통적인 방법과의 비교를 하자면, 기존에는 모든 사용자 포인터를 소프트웨어로 검증해야 했다면(validate_user_pointer), 이제는 SMAP이 하드웨어 레벨에서 자동으로 차단하므로 검증 코드를 빠뜨려도 공격이 성공하지 못합니다. SMAP과 SMEP의 핵심 특징은 다음과 같습니다.
첫째, CR4 레지스터의 비트를 설정하는 것만으로 전체 커널이 보호됩니다. 둘째, 필요할 때만 RFLAGS의 AC(Alignment Check) 비트로 일시적으로 비활성화할 수 있어 copy_from_user 같은 정당한 접근은 허용합니다.
셋째, 성능 오버헤드가 거의 없어 항상 활성화해도 문제없습니다. 넷째, ret2usr, ret2dir 같은 고급 공격 기법을 효과적으로 방어합니다.
이러한 특징들이 커널 보안을 크게 강화합니다.
코드 예제
use x86_64::registers::control::{Cr4, Cr4Flags};
use x86_64::registers::rflags::{RFlags, self};
pub struct SmapSmepManager;
impl SmapSmepManager {
/// 부팅 시 SMAP/SMEP 활성화
pub fn enable() {
// 1. CPU가 SMAP/SMEP을 지원하는지 확인
if !cpu_supports_smap() {
log::warn!("CPU가 SMAP을 지원하지 않음");
return;
}
if !cpu_supports_smep() {
log::warn!("CPU가 SMEP을 지원하지 않음");
return;
}
// 2. CR4 레지스터에 플래그 설정
unsafe {
let mut cr4 = Cr4::read();
// SMAP 활성화 (bit 21)
cr4.insert(Cr4Flags::SUPERVISOR_MODE_ACCESS_PREVENTION);
// SMEP 활성화 (bit 20)
cr4.insert(Cr4Flags::SUPERVISOR_MODE_EXECUTION_PREVENTION);
Cr4::write(cr4);
}
log::info!("SMAP/SMEP 활성화 완료");
}
/// 사용자 메모리에 안전하게 접근하기 위해 SMAP 일시 비활성화
#[inline(always)]
pub unsafe fn stac() {
// RFLAGS.AC 비트 설정 (Set AC)
// AC=1이면 SMAP이 일시적으로 비활성화됨
asm!("stac", options(nomem, nostack));
}
/// 사용자 메모리 접근 완료 후 SMAP 재활성화
#[inline(always)]
pub unsafe fn clac() {
// RFLAGS.AC 비트 해제 (Clear AC)
// AC=0이면 SMAP이 다시 활성화됨
asm!("clac", options(nomem, nostack));
}
}
/// SMAP을 고려한 안전한 사용자 메모리 복사
pub unsafe fn copy_from_user_safe<T>(user_ptr: VirtAddr) -> Result<T, MemoryError> {
// 1. 주소 검증 (커널 영역 차단)
if user_ptr.as_u64() >= KERNEL_BASE {
return Err(MemoryError::KernelAddressAccess);
}
// 2. SMAP 일시 비활성화
SmapSmepManager::stac();
// 3. 사용자 메모리에서 데이터 읽기
let result = core::ptr::read_volatile(user_ptr.as_ptr::<T>());
// 4. SMAP 재활성화 (중요! 빠뜨리면 안 됨)
SmapSmepManager::clac();
Ok(result)
}
/// SMAP/SMEP 위반 시 페이지 폴트 핸들러에서 감지
pub extern "x86-interrupt" fn page_fault_handler_with_smap(
stack_frame: InterruptStackFrame,
error_code: PageFaultErrorCode,
) {
let faulting_address = Cr2::read();
// SMAP 위반 감지
if !error_code.contains(PageFaultErrorCode::USER_MODE) {
// 커널 모드에서 발생한 폴트
let page = Page::containing_address(faulting_address);
let flags = get_page_flags(page);
if flags.contains(PageTableFlags::USER_ACCESSIBLE) {
// 커널이 사용자 페이지에 접근 시도 -> SMAP 위반
panic!(
"SMAP 위반: 커널이 사용자 메모리 접근 시도\n\
주소: {:#x}\n\
명령어 포인터: {:#x}\n\
이는 커널 버그 또는 공격 시도일 수 있습니다.",
faulting_address,
stack_frame.instruction_pointer
);
}
}
// 일반 페이지 폴트 처리...
}
/// CPU의 SMAP/SMEP 지원 확인 (CPUID)
fn cpu_supports_smap() -> bool {
// CPUID.07H:EBX.SMAP[bit 20]
let cpuid = unsafe { core::arch::x86_64::__cpuid_count(7, 0) };
cpuid.ebx & (1 << 20) != 0
}
fn cpu_supports_smep() -> bool {
// CPUID.07H:EBX.SMEP[bit 7]
let cpuid = unsafe { core::arch::x86_64::__cpuid_count(7, 0) };
cpuid.ebx & (1 << 7) != 0
}
설명
이것이 하는 일: SMAP과 SMEP은 Intel과 AMD CPU가 제공하는 하드웨어 보안 기능으로, 커널 모드에서 사용자 메모리에 대한 접근과 실행을 자동으로 차단합니다. 이를 통해 커널 버그나 취약점을 악용한 공격을 원천적으로 방어할 수 있습니다.
코드를 단계별로 나누어 설명하겠습니다. 첫 번째로, enable 함수에서 부팅 시 SMAP/SMEP을 활성화합니다.
CPUID 명령어로 CPU가 이 기능을 지원하는지 확인한 후, CR4 레지스터의 해당 비트를 설정합니다. CR4는 CPU의 다양한 보안 기능을 제어하는 특수 레지스터이며, SMAP은 bit 21, SMEP은 bit 20에 해당합니다.
이 비트들을 설정하는 순간부터 모든 커널 코드가 자동으로 보호받습니다. 그 다음으로, stac()과 clac() 함수가 SMAP을 일시적으로 제어합니다.
copy_from_user 같이 정당하게 사용자 메모리에 접근해야 하는 경우, stac 명령어로 RFLAGS의 AC(Alignment Check) 비트를 설정하여 SMAP을 잠시 비활성화합니다. 데이터를 읽은 후에는 즉시 clac 명령어로 AC 비트를 해제하여 SMAP을 재활성화합니다.
이 패턴은 매우 중요한데, clac을 빠뜨리면 그 이후의 커널 코드가 SMAP 보호를 받지 못하게 됩니다. 세 번째 단계에서는 copy_from_user_safe가 SMAP을 고려한 안전한 복사를 수행합니다.
먼저 주소가 커널 영역이 아닌지 소프트웨어로 검증합니다. 이는 이중 방어(defense in depth) 전략의 일부입니다.
그 다음 stac으로 SMAP을 비활성화하고 데이터를 읽은 후, 반드시 clac으로 재활성화합니다. 이 함수는 inline으로 선언되어 성능 오버헤드가 최소화됩니다.
네 번째 단계에서는 페이지 폴트 핸들러가 SMAP 위반을 감지합니다. 커널 모드에서 발생한 폴트(USER_MODE 비트 없음)인데 해당 페이지가 USER_ACCESSIBLE 플래그를 가지고 있다면, 이는 SMAP 위반입니다.
정상적인 코드라면 stac을 호출했을 것이므로, SMAP 위반이 발생했다는 것은 버그(검증 누락)이거나 공격 시도를 의미합니다. 명령어 포인터(RIP)를 함께 출력하여 어떤 커널 함수에서 위반이 발생했는지 파악할 수 있습니다.
마지막으로, SMEP의 작동 방식을 이해하는 것이 중요합니다. SMEP은 별도의 stac/clac 같은 일시 비활성화 메커니즘이 없습니다.
왜냐하면 커널이 사용자 코드를 실행해야 하는 정당한 경우가 없기 때문입니다. 만약 커널이 사용자 페이지의 주소로 점프하려고 하면(ret2usr 공격), CPU가 즉시 페이지 폴트를 발생시켜 차단합니다.
이는 커널 ROP 공격에서 사용자 공간의 가젯을 사용하는 것을 방지합니다. 여러분이 이 코드를 사용하면 커널 익스플로잇의 난이도를 크게 높일 수 있습니다.
공격자가 커널 취약점을 찾아도 SMAP 때문에 사용자 메모리를 통해 커널 데이터를 조작할 수 없고, SMEP 때문에 사용자 공간의 쉘코드를 실행할 수도 없습니다. 또한 커널 개발 시 실수로 사용자 포인터 검증을 빠뜨려도 SMAP이 런타임에 감지하여 조기에 버그를 발견할 수 있습니다.
실전 팁
💡 stac/clac은 성능에 민감한 경로에서 사용하세요. 대량의 데이터를 복사할 때는 한 번만 stac하고 모두 복사한 후 clac하는 것이 효율적입니다. 매 바이트마다 토글하지 마세요.
💡 예외 처리 경로에서 clac을 빠뜨리지 않도록 주의하세요. Rust의 Drop 트레이트나 RAII 패턴을 활용해 스코프를 벗어날 때 자동으로 clac이 호출되도록 만드는 것을 권장합니다.
💡 KPTI(Kernel Page Table Isolation)와 SMAP을 함께 사용하면 더 강력합니다. KPTI는 Meltdown을 방어하고, SMAP은 커널 버그를 방어하여 다층 보안을 달성합니다.
💡 커널 모듈이나 드라이버 개발 시 SMAP을 테스트 도구로 활용하세요. 사용자 포인터를 잘못 다루면 즉시 페이지 폴트가 발생하여 버그를 빠르게 찾을 수 있습니다.
💡 ARM에서는 PAN(Privileged Access Never)이 SMAP과 동일한 역할을 합니다. 크로스 플랫폼 커널을 개발한다면 아키텍처별 추상화 계층을 만들어 동일한 인터페이스로 사용하세요.
8. 메모리 태깅과 MTE (Memory Tagging Extension)
시작하며
여러분이 메모리 안전성 버그를 추적할 때 이런 악몽을 경험한 적 있나요? Use-after-free 버그로 해제된 메모리를 접근했는데, 우연히 그 메모리가 다른 객체에 재할당되어 있어서 오류가 발생하지 않고 조용히 데이터가 손상되는 상황.
또는 버퍼 오버플로우가 인접한 객체를 덮어쓰는데 즉시 크래시하지 않아 디버깅이 불가능한 경우. 이런 문제는 현대 소프트웨어의 가장 심각한 보안 취약점 원인입니다.
Google Project Zero에 따르면 모든 보안 버그의 약 70%가 메모리 안전성 문제이며, 이 중 상당수가 use-after-free와 버퍼 오버플로우입니다. 이러한 버그는 탐지하기 어렵고 익스플로잇하기 쉬워 공격자들이 선호하는 취약점 유형입니다.
바로 이럴 때 필요한 것이 메모리 태깅(Memory Tagging)입니다. 특히 ARM의 MTE(Memory Tagging Extension)는 각 메모리 할당에 4비트 태그를 붙여 포인터의 태그와 메모리의 태그가 일치하는지 하드웨어 레벨에서 검증합니다.
개요
간단히 말해서, 메모리 태깅은 포인터와 메모리 영역에 각각 태그를 붙이고, 접근 시마다 CPU가 자동으로 태그를 비교하여 불일치하면 즉시 예외를 발생시켜 메모리 안전성 버그를 실시간으로 탐지하는 하드웨어 기능입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 기존의 메모리 안전성 도구(AddressSanitizer, Valgrind 등)는 성능 오버헤드가 2-10배에 달해 프로덕션 환경에서 사용하기 어렵습니다.
예를 들어, 안드로이드 시스템 서비스나 브라우저 렌더러 같이 성능이 중요한 컴포넌트에서 메모리 버그를 탐지하려면 오버헤드가 낮은 하드웨어 지원이 필수적입니다. MTE는 약 10-25%의 오버헤드로 프로덕션에서도 활성화할 수 있습니다.
전통적인 방법과의 비교를 하자면, 기존에는 소프트웨어 계측(instrumentation)으로 모든 메모리 접근을 검사했다면, 이제는 CPU가 하드웨어 레벨에서 태그를 비교하므로 훨씬 빠르고 우회할 수 없습니다. 메모리 태깅의 핵심 특징은 다음과 같습니다.
첫째, 포인터의 상위 4비트를 태그로 사용하며 ARMv8.5-A부터 지원됩니다. 둘째, 메모리는 16바이트 단위(granule)로 태그가 저장되어 세밀한 검증이 가능합니다.
셋째, 할당 시 랜덤 태그를 부여하여 use-after-free 시 태그 불일치로 즉시 탐지됩니다. 넷째, Synchronous와 Asynchronous 모드를 선택할 수 있어 성능과 정확성을 조절 가능합니다.
이러한 특징들이 실용적인 메모리 안전성 검증을 가능하게 합니다.
코드 예제
// ARM MTE (Memory Tagging Extension) 예제
// 주의: 이 코드는 ARMv8.5-A 이상에서만 작동합니다
use core::arch::asm;
#[repr(C, align(16))]
pub struct TaggedAllocation {
data: *mut u8,
size: usize,
tag: u8, // 4비트 태그 (0-15)
}
impl TaggedAllocation {
/// MTE를 사용한 안전한 메모리 할당
pub fn allocate(size: usize) -> Result<Self, AllocError> {
// 1. 메모리 할당 (16바이트 정렬 필수)
let layout = Layout::from_size_align(size, 16)?;
let ptr = unsafe { alloc(layout) };
if ptr.is_null() {
return Err(AllocError::OutOfMemory);
}
// 2. 랜덤 태그 생성 (0-15)
let tag = generate_random_tag();
// 3. 포인터에 태그 삽입 (상위 4비트)
let tagged_ptr = Self::insert_tag(ptr, tag);
// 4. 메모리 영역에 태그 설정
unsafe {
Self::set_memory_tag(tagged_ptr, size, tag);
}
Ok(TaggedAllocation {
data: tagged_ptr,
size,
tag,
})
}
/// 포인터에 태그 삽입 (ARMv8.5-A TBI 활용)
#[inline(always)]
fn insert_tag(ptr: *mut u8, tag: u8) -> *mut u8 {
// 상위 4비트에 태그 삽입 (bits 56-59)
let addr = ptr as usize;
let tagged = (addr & 0x00FF_FFFF_FFFF_FFFF) | ((tag as usize) << 56);
tagged as *mut u8
}
/// 메모리 영역에 태그 설정 (MTE 명령어 사용)
unsafe fn set_memory_tag(ptr: *mut u8, size: usize, tag: u8) {
// 16바이트 단위로 태그 설정
let granules = (size + 15) / 16;
for i in 0..granules {
let granule_ptr = ptr.add(i * 16);
// STG 명령어: 메모리에 태그 저장
// (Store Allocation Tag)
asm!(
"stg {ptr}, [{ptr}]",
ptr = in(reg) granule_ptr,
options(nostack)
);
}
}
/// 안전한 메모리 접근 (태그 검증 자동)
pub fn write(&mut self, offset: usize, value: u8) -> Result<(), AccessError> {
if offset >= self.size {
return Err(AccessError::OutOfBounds);
}
unsafe {
// CPU가 자동으로 태그 검증
// 태그 불일치 시 예외 발생 (Synchronous 모드)
let ptr = self.data.add(offset);
core::ptr::write(ptr, value);
}
Ok(())
}
/// 메모리 해제 (태그 무효화)
pub fn deallocate(&mut self) {
// 5. 다른 태그로 변경하여 무효화
let new_tag = (self.tag + 1) % 16;
unsafe {
Self::set_memory_tag(self.data, self.size, new_tag);
// 원본 포인터(태그 제거)로 해제
let untagged = (self.data as usize) & 0x00FF_FFFF_FFFF_FFFF;
let layout = Layout::from_size_align_unchecked(self.size, 16);
dealloc(untagged as *mut u8, layout);
}
// 포인터는 여전히 이전 태그를 가지고 있음
// 다시 접근하면 태그 불일치로 예외 발생!
}
}
/// MTE 예외 핸들러
#[no_mangle]
pub extern "C" fn mte_exception_handler(addr: usize, access_type: AccessType) {
let ptr_tag = (addr >> 56) & 0xF;
let mem_tag = unsafe { get_memory_tag(addr) };
panic!(
"MTE 태그 불일치 감지!\n\
주소: {:#x}\n\
포인터 태그: {}\n\
메모리 태그: {}\n\
접근 유형: {:?}\n\
이는 use-after-free 또는 버퍼 오버플로우일 수 있습니다.",
addr, ptr_tag, mem_tag, access_type
);
}
/// MTE 활성화 (프로세스 시작 시)
pub fn enable_mte() {
unsafe {
// TCO (Tag Check Override) 비트 해제
// PSTATE.TCO = 0 -> 태그 검사 활성화
asm!(
"msr tco, #0",
options(nostack, nomem)
);
// Synchronous 모드 설정 (즉시 예외 발생)
// SCTLR_EL1.TCF = 0b01
asm!(
"mrs x0, sctlr_el1",
"orr x0, x0, #(1 << 40)",
"msr sctlr_el1, x0",
out("x0") _,
options(nostack)
);
}
}
설명
이것이 하는 일: MTE(Memory Tagging Extension)는 ARM CPU가 제공하는 하드웨어 메모리 안전성 기능으로, 모든 포인터와 메모리 영역에 태그를 붙이고 접근 시마다 자동으로 검증하여 메모리 안전성 버그를 즉시 탐지합니다. x86에는 아직 유사한 기능이 없어 ARM의 차별화된 보안 기능입니다.
코드를 단계별로 나누어 설명하겠습니다. 첫 번째로, allocate 함수에서 메모리를 할당할 때 랜덤 태그를 생성합니다.
태그는 0-15 범위의 4비트 값이며, 랜덤하게 선택하는 이유는 use-after-free 시 이전 포인터의 태그와 새로 할당된 메모리의 태그가 일치할 확률을 1/16(6.25%)로 낮추기 위함입니다. 만약 태그가 일치하지 않으면 해제된 포인터로 접근해도 CPU가 즉시 예외를 발생시킵니다.
그 다음으로, insert_tag와 set_memory_tag가 포인터와 메모리에 각각 태그를 설정합니다. ARM 아키텍처는 TBI(Top Byte Ignore) 기능으로 포인터의 상위 비트를 무시하므로, 56-59번 비트에 태그를 저장해도 주소 계산에 영향을 주지 않습니다.
메모리 태그는 별도의 태그 메모리 영역에 16바이트 단위(granule)로 저장되며, STG(Store Allocation Tag) 명령어로 설정합니다. 이 태그 메모리는 일반 로드/스토어로 접근할 수 없고 특수 명령어로만 접근 가능합니다.
세 번째 단계에서는 write 메서드가 메모리에 접근할 때 CPU가 자동으로 태그를 검증합니다. core::ptr::write를 호출하는 순간, CPU는 다음 작업을 자동으로 수행