mprotect (메모리 보호 변경)
Linux 커널 mprotect 시스콜 심화: PROT_READ/WRITE/EXEC/NONE 보호 플래그, do_mprotect_pkey 커널 구현, VMA 분할/병합, 페이지 테이블 업데이트, Memory Protection Keys(MPK), W^X 정책, SELinux/AppArmor 제약, COW 상호작용, JIT 컴파일러/가드 페이지/샌드박스 활용 종합 가이드.
핵심 요약
- mprotect() -- 이미 매핑된 메모리 영역의 접근 권한(읽기/쓰기/실행)을 동적으로 변경하는 POSIX 시스콜입니다.
- PROT 플래그 --
PROT_READ,PROT_WRITE,PROT_EXEC,PROT_NONE의 조합으로 보호 속성을 지정합니다. - VMA 분할/병합 -- 보호 속성 변경 시 기존 VMA를 분할(split)하거나 인접 VMA와 병합(merge)하여 관리합니다.
- 페이지 테이블 업데이트 -- 커널은 대상 영역의 모든 PTE(Page Table Entry)를 순회하며 권한 비트를 갱신합니다.
- MPK (Memory Protection Keys) -- Intel/AMD의 하드웨어 기반 메모리 보호 키로, 시스콜 없이 사용자 공간에서 권한을 전환할 수 있습니다.
단계별 이해
- 사용자 공간에서 mprotect() 호출
프로세스가mprotect(addr, len, prot)를 호출하면 커널로 트랩됩니다. addr은 페이지 정렬이어야 합니다. - 커널이 VMA를 찾고 검증
커널은 주소 범위에 해당하는 VMA를 찾아 보호 변경이 유효한지 검증합니다. LSM(SELinux 등)도 여기서 호출됩니다. - VMA 분할 및 속성 변경
변경 범위가 VMA 일부만 포함하면 VMA를 분할합니다. 그 후vm_flags를 새 보호 플래그로 갱신합니다. - 페이지 테이블 갱신 및 TLB 무효화
대상 영역의 모든 PTE를 순회하며 권한 비트를 변경하고, TLB를 플러시하여 변경 사항을 즉시 반영합니다.
개요
mprotect()는 프로세스의 가상 주소 공간에서 이미 매핑된 메모리 영역의 접근 보호 속성을 변경하는 시스콜입니다.
POSIX 표준에 정의되어 있으며, 메모리 보안의 핵심 메커니즘 중 하나입니다.
일반적인 사용 시나리오는 다음과 같습니다:
- JIT 컴파일러가 코드 생성 후 메모리를 실행 가능하게 전환
- 가드 페이지를
PROT_NONE으로 설정하여 스택 오버플로 탐지 - 읽기 전용으로 전환하여 데이터 무결성 보장
- 메모리 디버거가 접근 패턴을 추적
#include <sys/mman.h>
/* mprotect 기본 인터페이스 */
int mprotect(void *addr, size_t len, int prot);
/* pkey_mprotect: Memory Protection Keys 확장 */
int pkey_mprotect(void *addr, size_t len, int prot, int pkey);
/*
* addr : 페이지 정렬된 시작 주소
* len : 변경할 영역 크기 (바이트, 페이지 단위로 올림)
* prot : PROT_READ | PROT_WRITE | PROT_EXEC | PROT_NONE
* pkey : 메모리 보호 키 (pkey_mprotect 전용, 0~15)
*
* 반환: 성공 시 0, 실패 시 -1 (errno 설정)
*/
코드 설명
-
4행
mprotect()는 POSIX 표준 시스콜입니다. addr은 반드시 페이지 경계에 정렬되어야 합니다. -
7행
pkey_mprotect()는 Linux 4.9에서 추가된 확장으로, 메모리 보호 키(pkey)를 함께 지정합니다. -
14행
PROT 플래그는 비트 OR로 조합합니다.
PROT_NONE은 단독 사용하며 모든 접근을 차단합니다.
mprotect 시스콜 인터페이스
mprotect의 보호 플래그는 4가지 기본 값과 아키텍처별 확장으로 구성됩니다. 이 플래그들은 비트 OR로 조합하여 사용합니다.
| 플래그 | 값 | 설명 | PTE 비트 (x86-64) |
|---|---|---|---|
PROT_NONE | 0x0 | 모든 접근 차단 | Present=0 (또는 특수 인코딩) |
PROT_READ | 0x1 | 읽기 허용 | Present=1, User=1 |
PROT_WRITE | 0x2 | 쓰기 허용 | R/W=1 |
PROT_EXEC | 0x4 | 실행 허용 | NX=0 (No Execute 해제) |
PROT_GROWSDOWN | 0x01000000 | 스택처럼 하향 확장 가능 | VMA 플래그 전용 |
PROT_GROWSUP | 0x02000000 | 상향 확장 가능 (IA-64) | VMA 플래그 전용 |
/* include/uapi/asm-generic/mman-common.h */
#define PROT_NONE 0x0 /* 페이지 접근 불가 */
#define PROT_READ 0x1 /* 페이지 읽기 가능 */
#define PROT_WRITE 0x2 /* 페이지 쓰기 가능 */
#define PROT_EXEC 0x4 /* 페이지 실행 가능 */
#define PROT_GROWSDOWN 0x01000000 /* 스택 하향 확장 */
#define PROT_GROWSUP 0x02000000 /* 상향 확장 (IA-64) */
커널 내부에서 PROT 플래그는 vm_flags로 변환됩니다. 이 변환은 아키텍처별 protection_map[] 테이블을 통해 수행됩니다:
/* mm/mmap.c - PROT 플래그 → VM 플래그 변환 */
static unsigned long calc_vm_prot_bits(unsigned long prot, unsigned long pkey)
{
return _calc_vm_trans(prot, PROT_READ, VM_READ) |
_calc_vm_trans(prot, PROT_WRITE, VM_WRITE) |
_calc_vm_trans(prot, PROT_EXEC, VM_EXEC) |
arch_calc_vm_prot_bits(prot, pkey);
}
/* 변환 예시:
* PROT_READ → VM_READ
* PROT_READ | PROT_WRITE → VM_READ | VM_WRITE
* PROT_READ | PROT_EXEC → VM_READ | VM_EXEC
* PROT_NONE → 0 (vm_flags에 RWX 비트 없음)
*/
mprotect 커널 구현
mprotect()의 커널 진입점은 do_mprotect_pkey()입니다.
이 함수는 mm/mprotect.c에 정의되어 있으며, 주소 검증, VMA 순회, LSM 검사, VMA 속성 변경을 담당합니다.
/* mm/mprotect.c - do_mprotect_pkey() 핵심 로직 (v6.x 기준 단순화) */
static int do_mprotect_pkey(unsigned long start, size_t len,
unsigned long prot, int pkey)
{
unsigned long nstart, end, tmp, reqprot;
struct vm_area_struct *vma, *prev;
int error;
const bool rier = (current->personality & READ_IMPLIES_EXEC)
&& (prot & PROT_READ);
/* 1. 주소 정렬 및 범위 검증 */
start = untagged_addr(start);
prot &= ~(PROT_GROWSDOWN | PROT_GROWSUP);
if (start & ~PAGE_MASK)
return -EINVAL;
len = PAGE_ALIGN(len);
end = start + len;
if (end <= start)
return -ENOMEM;
/* 2. 요청된 보호를 vm_flags로 변환 */
reqprot = prot;
unsigned long newflags = calc_vm_prot_bits(prot, pkey);
/* 3. VMA 순회: 범위에 걸친 모든 VMA 처리 */
vma = find_vma(current->mm, start);
for (nstart = start; nstart < end; nstart = tmp) {
if (!vma || vma->vm_start >= end)
return -ENOMEM;
tmp = vma->vm_end;
if (tmp > end)
tmp = end;
/* 4. LSM(보안 모듈) 검사 */
error = security_file_mprotect(vma, reqprot, prot);
if (error)
return error;
/* 5. VMA 속성 변경 (분할/병합 포함) */
error = mprotect_fixup(vma, &prev, nstart, tmp, newflags);
if (error)
return error;
vma = prev->vm_next;
}
return 0;
}
코드 설명
-
8-9행
READ_IMPLIES_EXEC: 구형 바이너리 호환성을 위해 읽기 권한에 실행 권한을 암묵적으로 부여하는 personality 플래그입니다. - 12-19행 주소 정렬 검사: start가 PAGE_SIZE 경계에 정렬되지 않으면 EINVAL 반환. len은 페이지 단위로 올림합니다.
-
23행
calc_vm_prot_bits()로 사용자 PROT 플래그를 VM_READ/VM_WRITE/VM_EXEC 조합으로 변환합니다. -
26-27행
find_vma()로 시작 주소를 포함하는 VMA를 찾고, 범위 내 모든 VMA를 순회합니다. -
35-37행
security_file_mprotect(): LSM(SELinux, AppArmor 등)이 보호 변경을 허용하는지 검사합니다. -
40행
mprotect_fixup(): 실제 VMA 분할/병합과 페이지 테이블 업데이트를 수행하는 핵심 함수입니다.
VMA 분할과 병합
mprotect가 VMA의 일부만 대상으로 할 때, 커널은 기존 VMA를 분할(split)하고, 보호 속성이 같은 인접 VMA가 있으면 병합(merge)합니다.
이 과정은 mprotect_fixup()에서 수행됩니다.
/* mm/mprotect.c - mprotect_fixup() 핵심 로직 */
int mprotect_fixup(struct vma_iterator *vmi,
struct vm_area_struct *vma,
struct vm_area_struct **pprev,
unsigned long start, unsigned long end,
unsigned long newflags)
{
struct mm_struct *mm = vma->vm_mm;
unsigned long oldflags = vma->vm_flags;
pgprot_t newprot;
/* 변경 필요 없음 → 바로 반환 */
if (newflags == oldflags) {
*pprev = vma;
return 0;
}
/* 1. 인접 VMA와 병합 시도 */
vma = vma_merge(vmi, mm, *pprev, start, end,
newflags, vma->anon_vma, vma->vm_file,
vma->vm_pgoff, vma_policy(vma),
vma->vm_userfaultfd_ctx);
if (vma)
goto success;
/* 2. 병합 실패 → 시작 부분 분할 */
if (start != vma->vm_start) {
error = split_vma(vmi, vma, start, 1);
if (error)
return error;
}
/* 3. 끝 부분 분할 */
if (end != vma->vm_end) {
error = split_vma(vmi, vma, end, 0);
if (error)
return error;
}
success:
/* 4. vm_flags 갱신 */
vm_flags_reset(vma, newflags);
/* dirty accountting 업데이트 */
vma_set_page_prot(vma);
/* 5. 페이지 테이블 업데이트 */
change_protection(vma, start, end, newprot,
dirty_accountable ? MM_CP_DIRTY_ACCT : 0);
*pprev = vma;
return 0;
}
코드 설명
- 12-16행 새 플래그가 기존 플래그와 동일하면 변경할 필요가 없으므로 즉시 반환합니다. 불필요한 TLB 플러시를 방지합니다.
-
19-24행
vma_merge(): 새 보호 속성이 인접 VMA와 동일하면 병합합니다. 성공 시 split 없이 바로 진행합니다. -
27-31행
변경 시작점이 VMA 시작과 다르면
split_vma()로 앞부분을 분리합니다. - 34-38행 변경 끝점이 VMA 끝과 다르면 뒷부분을 분리합니다. 이로써 정확히 요청 범위만 남습니다.
-
42-43행
VMA의
vm_flags를 새 보호 플래그로 갱신하고vm_page_prot를 재계산합니다. -
46-47행
change_protection()으로 실제 페이지 테이블 엔트리를 순회하며 권한 비트를 변경합니다.
페이지 테이블 업데이트
VMA 속성이 변경된 후, change_protection()이 호출되어 실제 페이지 테이블 엔트리를 갱신합니다.
이 함수는 페이지 디렉토리 계층을 순회하며 각 PTE의 권한 비트를 수정합니다.
/* mm/mprotect.c - change_protection() → change_pXd_range() 호출 체인 */
unsigned long change_protection(
struct mmu_gather *tlb,
struct vm_area_struct *vma,
unsigned long start, unsigned long end,
pgprot_t newprot, unsigned long cp_flags)
{
unsigned long pages;
BUG_ON((cp_flags & MM_CP_UFFD_WP_ALL) == MM_CP_UFFD_WP_ALL);
/* TLB gather 시작 */
tlb_gather_mmu(tlb, vma->vm_mm);
/* 페이지 디렉토리 계층 순회 */
pages = change_protection_range(tlb, vma, start, end,
newprot, cp_flags);
/* TLB 일괄 무효화 */
tlb_finish_mmu(tlb);
return pages;
}
/* PTE 레벨 처리 - 가장 하위 단계 */
static unsigned long change_pte_range(
struct vm_area_struct *vma, pmd_t *pmd,
unsigned long addr, unsigned long end,
pgprot_t newprot, unsigned long cp_flags)
{
pte_t *pte, oldpte, ptent;
spinlock_t *ptl;
unsigned long pages = 0;
pte = pte_offset_map_lock(vma->vm_mm, pmd, addr, &ptl);
for (; addr < end; pte++, addr += PAGE_SIZE) {
oldpte = ptep_get(pte);
if (pte_present(oldpte)) {
/* PTE 권한 비트 갱신 */
ptent = pte_modify(oldpte, newprot);
ptent = pte_mkold(ptent); /* Accessed 비트 초기화 */
ptep_modify_prot_commit(vma, addr, pte, oldpte, ptent);
pages++;
}
}
pte_unmap_unlock(pte - 1, ptl);
return pages;
}
코드 설명
-
13행
tlb_gather_mmu(): TLB 플러시를 일괄 처리하기 위한 구조체를 초기화합니다. 개별 플러시보다 효율적입니다. -
16-17행
change_protection_range()는 PGD → P4D → PUD → PMD → PTE 순으로 계층적으로 순회합니다. -
20행
tlb_finish_mmu(): 수집된 TLB 엔트리를 한 번에 플러시합니다. IPI(Inter-Processor Interrupt)로 다른 CPU에도 전파됩니다. -
35행
pte_offset_map_lock(): PTE 테이블을 매핑하고 spinlock을 획득합니다. 동시성 보호 핵심입니다. -
40행
pte_modify(): 기존 PTE에서 PFN은 유지하고 권한 비트만 newprot로 교체합니다. -
42행
ptep_modify_prot_commit(): 원자적으로 PTE를 교체하고 TLB 플러시 대상에 추가합니다.
pkey와 MPK (Memory Protection Keys)
Intel의 Memory Protection Keys(MPK)는 하드웨어 기반의 고속 메모리 보호 메커니즘입니다.
각 페이지에 4비트 보호 키(pkey, 0~15)를 할당하고, 사용자 공간에서 PKRU(Protection Key Rights for User pages) 레지스터를 직접 수정하여
시스콜 없이 접근 권한을 전환할 수 있습니다.
/* pkey_mprotect() 시스콜로 메모리에 보호 키 할당 */
#include <sys/mman.h>
int pkey = pkey_alloc(0, PKEY_DISABLE_WRITE);
if (pkey == -1) perror("pkey_alloc");
/* 메모리 영역에 pkey 할당 */
void *buf = mmap(NULL, 4096, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
pkey_mprotect(buf, 4096, PROT_READ | PROT_WRITE, pkey);
/* PKRU 레지스터로 접근 제어 (시스콜 없이!) */
pkey_set(pkey, PKEY_DISABLE_WRITE); /* 쓰기 차단 */
/* buf에 쓰기 시도 → SIGSEGV */
pkey_set(pkey, 0); /* 모든 접근 허용 */
/* buf에 정상 접근 가능 */
pkey_free(pkey); /* 보호 키 해제 */
코드 설명
-
4행
pkey_alloc(): 사용 가능한 보호 키를 할당합니다. 최대 16개(0~15)이며, 0은 기본값으로 예약됩니다. -
10행
pkey_mprotect(): 일반 mprotect에 pkey 파라미터를 추가하여 메모리에 보호 키를 할당합니다. -
13행
pkey_set()은 내부적으로WRPKRU명령어를 실행합니다. 이 명령어는 20사이클 미만의 비용으로 매우 빠릅니다.
W^X 정책 (Write XOR Execute)
W^X(Write XOR Execute)는 메모리 페이지가 동시에 쓰기 가능(W)이면서 실행 가능(X)일 수 없다는 보안 정책입니다. 이 정책은 코드 주입 공격(buffer overflow → shellcode 실행)을 방지하는 핵심 메커니즘입니다.
/* mm/mprotect.c - W^X 위반 탐지 (커널 v5.8+ 경고) */
static bool is_wx_mapping(unsigned long vm_flags)
{
return (vm_flags & (VM_WRITE | VM_EXEC)) == (VM_WRITE | VM_EXEC);
}
/* SELinux에서 W^X 강제 적용 */
static int selinux_file_mprotect(
struct vm_area_struct *vma,
unsigned long reqprot, unsigned long prot)
{
/* execmem 권한 없이 W+X 동시 요청 → -EACCES */
if ((prot & PROT_EXEC) &&
!(vma->vm_flags & VM_EXEC)) {
/* 실행 권한 추가 시 execmem 권한 필요 */
return avc_has_perm(sid, sid,
SECCLASS_PROCESS, PROCESS__EXECMEM, NULL);
}
return 0;
}
SELinux/AppArmor와 mprotect 제약
LSM(Linux Security Module) 프레임워크는 mprotect 호출 시 security_file_mprotect() 훅을 통해 보안 정책을 강제합니다.
SELinux와 AppArmor는 각각 다른 방식으로 mprotect를 제한합니다.
| LSM | 정책 | 제한 내용 | 우회 조건 |
|---|---|---|---|
| SELinux | execmem | 익명 메모리에 PROT_EXEC 추가 차단 | allow process execmem |
| SELinux | execmod | 쓰기된 파일 매핑에 PROT_EXEC 추가 차단 | allow process execmod |
| SELinux | execheap | 힙 영역에 PROT_EXEC 추가 차단 | allow process execheap |
| AppArmor | mx | mmap PROT_EXEC 차단 (프로파일별) | 프로파일에 mx 규칙 추가 |
/* security/selinux/hooks.c - SELinux mprotect 검사 로직 */
static int selinux_file_mprotect(
struct vm_area_struct *vma,
unsigned long reqprot, unsigned long prot)
{
const struct cred *cred = current_cred();
if (prot & PROT_EXEC) {
if (vma->vm_flags & VM_EXEC)
return 0; /* 이미 실행 가능 → 통과 */
if (vma->vm_file) {
/* 파일 매핑: execmod 권한 검사 */
if (vma->anon_vma) /* COW 후 수정된 매핑 */
return file_has_perm(cred, vma->vm_file,
FILE__EXECMOD);
} else {
/* 익명 매핑: execmem 권한 검사 */
return avc_has_perm(
current_sid(), current_sid(),
SECCLASS_PROCESS,
PROCESS__EXECMEM, NULL);
}
}
return 0;
}
코드 설명
- 8-10행 이미 실행 권한이 있는 VMA에 PROT_EXEC를 다시 설정하는 것은 허용합니다 (중복 설정).
-
12-16행
파일 매핑에서 COW로 수정된 페이지에 실행 권한을 부여하려면
execmod권한이 필요합니다. 공유 라이브러리의 텍스트 릴로케이션에 해당합니다. -
18-23행
익명 매핑(힙, 스택, mmap(MAP_ANONYMOUS))에 실행 권한을 부여하려면
execmem권한이 필요합니다. JIT 컴파일러가 이 권한을 필요로 합니다.
execmem 정책을 비활성화하면 코드 주입 공격에 취약해집니다.
JIT 컴파일러가 필요한 경우에만 특정 도메인에 execmem을 허용하고, 가능하면 MPK를 활용하세요.
mprotect와 COW (Copy-On-Write) 상호작용
mprotect와 COW는 복잡하게 상호작용합니다. 특히 쓰기 가능 → 읽기 전용으로 변경할 때, 기존 COW 상태의 페이지 처리가 핵심입니다.
/* mprotect_fixup()에서 dirty accounting 처리 */
static int mprotect_fixup(...)
{
int dirty_accountable = 0;
/* 쓰기 가능 → 쓰기 불가 전환 시 */
if ((oldflags & VM_WRITE) && !(newflags & VM_WRITE)) {
/* 공유 매핑이면 dirty accounting 갱신 필요 */
if (vma->vm_file && (vma->vm_flags & VM_SHARED))
dirty_accountable = 1;
}
/* 읽기 전용 → 쓰기 가능 전환 시 */
if (!(oldflags & VM_WRITE) && (newflags & VM_WRITE)) {
/*
* COW 페이지는 여전히 읽기 전용 PTE를 유지합니다.
* 실제 쓰기 시도 시 write fault가 발생하여 COW 복사가 이루어집니다.
* vm_flags에 VM_WRITE만 설정하고, PTE의 R/W 비트는
* COW 페이지에 대해서는 변경하지 않습니다.
*/
}
/* change_protection()은 COW 페이지의 PTE를 */
/* 쓰기 가능으로 만들지 않습니다. */
/* → write fault → do_wp_page() → COW 복사 유지 */
}
핵심 포인트: mprotect로 PROT_WRITE를 추가해도, COW 상태의 페이지는 PTE에서 읽기 전용을 유지합니다.
실제 쓰기 시 write fault가 발생하여 정상적인 COW 복사가 이루어집니다. 이는 fork() 후의 메모리 안전성을 보장합니다.
/* fork() → mprotect PROT_WRITE 시나리오 */
pid_t pid = fork();
if (pid == 0) { /* 자식 프로세스 */
/*
* fork() 후 부모/자식은 동일한 물리 페이지를 공유
* PTE는 읽기 전용으로 설정 (COW 상태)
*
* 자식이 mprotect(addr, len, PROT_READ | PROT_WRITE)를 호출해도
* COW 페이지의 PTE.R/W는 여전히 0
* → 쓰기 시도 → write fault → do_wp_page() → COW 복사
* → 새 물리 페이지에서 쓰기 진행
*/
mprotect(shared_buf, 4096, PROT_READ | PROT_WRITE);
shared_buf[0] = 42; /* write fault → COW 복사 → 쓰기 */
}
pkey_mprotect 시스콜
pkey_mprotect()는 Linux 4.9에서 추가된 시스콜로, 기존 mprotect의 기능에
Memory Protection Key(pkey) 할당을 결합합니다.
/* mm/mprotect.c - pkey_mprotect 시스콜 정의 */
SYSCALL_DEFINE4(pkey_mprotect,
unsigned long, start,
size_t, len,
unsigned long, prot,
int, pkey)
{
return do_mprotect_pkey(start, len, prot, pkey);
}
/* mprotect는 pkey=-1로 호출 */
SYSCALL_DEFINE3(mprotect,
unsigned long, start,
size_t, len,
unsigned long, prot)
{
return do_mprotect_pkey(start, len, prot, -1);
}
/* pkey 관련 시스콜 패밀리 */
SYSCALL_DEFINE2(pkey_alloc, unsigned long, flags, unsigned long, init_val)
{
/* 사용 가능한 pkey 할당 (0~15) */
int pkey = mm_pkey_alloc(current->mm);
if (pkey < 0)
return pkey;
/* PKRU에 초기 권한 설정 */
arch_set_user_pkey_access(current, pkey, init_val);
return pkey;
}
SYSCALL_DEFINE1(pkey_free, int, pkey)
{
return mm_pkey_free(current->mm, pkey);
}
코드 설명
-
2-8행
pkey_mprotect시스콜은 내부적으로do_mprotect_pkey()를 직접 호출합니다. pkey 값이 PTE의 59~62비트에 기록됩니다. -
12-17행
기존
mprotect시스콜은 pkey=-1로do_mprotect_pkey()를 호출합니다. pkey=-1은 "키 변경 없음"을 의미합니다. -
21-29행
pkey_alloc: 현재 프로세스의 mm에서 사용 가능한 pkey를 찾아 할당하고 PKRU 레지스터에 초기 권한을 설정합니다.
커널 설정과 보안 강화
mprotect 관련 커널 설정과 보안 강화 옵션을 정리합니다.
| 설정 | 기본값 | 설명 |
|---|---|---|
CONFIG_X86_INTEL_MEMORY_PROTECTION_KEYS | y (x86-64) | MPK 하드웨어 지원 활성화 |
CONFIG_ARCH_HAS_PKEYS | 자동 | 아키텍처별 pkey 지원 |
CONFIG_STRICT_KERNEL_RWX | y | 커널 코드/데이터 W^X 강제 |
CONFIG_STRICT_MODULE_RWX | y | 모듈 코드/데이터 W^X 강제 |
CONFIG_DEFAULT_MMAP_MIN_ADDR | 65536 | NULL 포인터 역참조 방지 최소 주소 |
CONFIG_SECURITY_SELINUX | 배포판별 | SELinux W^X 정책 활성화 |
# 커널 빌드 설정에서 MPK 관련 옵션 확인
$ zcat /proc/config.gz | grep -i pkey
CONFIG_X86_INTEL_MEMORY_PROTECTION_KEYS=y
CONFIG_ARCH_HAS_PKEYS=y
# 런타임에서 MPK 지원 여부 확인
$ grep pku /proc/cpuinfo
flags : ... pku ospke ...
# 현재 프로세스의 PKRU 값 확인
$ cat /proc/self/arch_status
AVX512_elapsed_ms: -1
PKRU: 0x55555554
# mprotect 추적 (ftrace)
$ echo 1 > /sys/kernel/debug/tracing/events/syscalls/sys_enter_mprotect/enable
$ cat /sys/kernel/debug/tracing/trace_pipe
/* /proc/PID/smaps에서 VMA 보호 속성 확인 */
/*
* 7f8a12000000-7f8a12400000 r-xp 00000000 fd:01 12345 /usr/lib/libc.so.6
* ^^^
* r = PROT_READ
* - = no PROT_WRITE
* x = PROT_EXEC
* p = MAP_PRIVATE
*
* VmFlags: rd ex mr mw me sd
* rd = VM_READ
* ex = VM_EXEC
* mr = VM_MAYREAD
* mw = VM_MAYWRITE (mprotect으로 쓰기 추가 가능)
* me = VM_MAYEXEC
* sd = VM_SOFTDIRTY
*/
mprotect 활용 패턴
JIT 컴파일러 패턴
JIT(Just-In-Time) 컴파일러는 런타임에 기계어 코드를 생성하고 실행합니다. W^X 정책을 준수하면서 코드를 생성하려면 mprotect를 활용해야 합니다.
#include <sys/mman.h>
#include <string.h>
/* JIT 컴파일러의 안전한 코드 생성 패턴 */
void *jit_compile(const uint8_t *bytecode, size_t len)
{
size_t page_size = sysconf(_SC_PAGESIZE);
size_t alloc_size = (len + page_size - 1) & ~(page_size - 1);
/* 1단계: RW로 할당 (쓰기 가능, 실행 불가) */
void *mem = mmap(NULL, alloc_size,
PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (mem == MAP_FAILED) return NULL;
/* 2단계: 기계어 코드 작성 */
generate_native_code(mem, bytecode, len);
/* 3단계: RX로 전환 (읽기+실행 가능, 쓰기 불가) */
if (mprotect(mem, alloc_size, PROT_READ | PROT_EXEC) != 0) {
munmap(mem, alloc_size);
return NULL;
}
return mem; /* 함수 포인터로 호출 가능 */
}
/* 사용 예시 */
typedef int (*jit_func_t)(int);
jit_func_t fn = (jit_func_t)jit_compile(bytecode, bytecode_len);
int result = fn(42); /* JIT 코드 실행 */
가드 페이지 패턴
/* 가드 페이지를 이용한 버퍼 오버플로 탐지 */
void *alloc_guarded(size_t size)
{
size_t page_size = sysconf(_SC_PAGESIZE);
size_t total = page_size + size + page_size;
/* 전체 영역 할당 */
void *base = mmap(NULL, total,
PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
/* 앞쪽 가드 페이지: PROT_NONE (접근 시 SIGSEGV) */
mprotect(base, page_size, PROT_NONE);
/* 뒤쪽 가드 페이지: PROT_NONE */
mprotect(base + page_size + size, page_size, PROT_NONE);
/* 사용 가능한 영역 반환 */
return base + page_size;
}
/*
* 메모리 레이아웃:
* [GUARD: PROT_NONE][사용 가능: RW][GUARD: PROT_NONE]
* ← 언더플로 탐지 →← 실제 데이터 →← 오버플로 탐지 →
*/
스택 보호 (glibc pthread)
/* glibc에서 스레드 스택 가드 페이지 설정 (nptl/allocatestack.c) */
static int allocate_stack(const struct pthread_attr *attr,
struct pthread **pdp, ...)
{
/* 스택 + 가드 페이지 할당 */
size_t guardsize = attr->guardsize;
void *mem = mmap(NULL, stacksize + guardsize, ...);
/* 스택 하단에 가드 페이지 설정 */
if (guardsize > 0) {
/* 스택은 아래로 성장하므로 하단에 PROT_NONE */
mprotect(mem, guardsize, PROT_NONE);
}
/*
* 스택 레이아웃 (x86-64):
* 높은 주소 → [스택 사용 영역 (RW)]
* [TLS/TCB]
* 낮은 주소 → [가드 페이지 (PROT_NONE)]
*
* 스택 오버플로 시 가드 페이지 접근 → SIGSEGV
*/
}
성능 특성과 주의사항
mprotect의 성능은 대상 영역의 크기와 매핑된 페이지 수에 비례합니다. 주요 비용 요소를 이해하고 최적화하는 것이 중요합니다.
- 가능하면 MPK(pkey)를 사용하여 시스콜 오버헤드를 회피하세요.
- mprotect 호출 횟수를 최소화하세요. 여러 영역을 한 번에 변경하는 것이 반복 호출보다 효율적입니다.
- THP(Transparent Huge Pages) 영역에서 mprotect는 huge page를 분할할 수 있으므로 주의하세요.
- 멀티코어 환경에서 TLB 플러시 비용(IPI)이 크므로, 빈번한 mprotect 호출을 피하세요.
| 비용 요소 | 설명 | 비례 관계 |
|---|---|---|
| 시스콜 오버헤드 | 사용자→커널 전환, 인자 검증 | 고정 비용 (~200ns) |
| VMA 탐색 | find_vma(), Maple Tree 순회 | O(log n), VMA 수에 비례 |
| VMA 분할 | split_vma() 호출 시 메모리 할당 | 고정 비용 (분할 필요 시) |
| PTE 순회 | change_pte_range() 페이지별 처리 | O(n), 페이지 수에 비례 |
| TLB 플러시 | flush_tlb_range(), IPI 전파 | CPU 수에 비례 |
실전 사용 사례
JIT 컴파일러 (V8, LuaJIT, Java HotSpot)
V8 엔진(Chrome/Node.js)은 JavaScript를 기계어로 컴파일할 때 mprotect를 사용합니다. 코드 생성 시 RW, 실행 시 RX로 전환하며, MPK를 지원하는 플랫폼에서는 pkey를 활용합니다.
/* V8 엔진의 코드 페이지 관리 (단순화) */
class CodePageAllocator {
void* AllocateCode(size_t size) {
/* MPK 사용 가능하면 pkey 할당 */
if (has_mpk_support_) {
void* mem = mmap(..., PROT_READ | PROT_WRITE, ...);
pkey_mprotect(mem, size,
PROT_READ | PROT_WRITE, code_pkey_);
return mem;
}
/* 폴백: 일반 mprotect 패턴 */
return mmap(..., PROT_READ | PROT_WRITE, ...);
}
void MakeExecutable(void* addr, size_t size) {
if (has_mpk_support_) {
/* WRPKRU로 쓰기 차단 (초고속) */
pkey_set(code_pkey_, PKEY_DISABLE_WRITE);
} else {
mprotect(addr, size, PROT_READ | PROT_EXEC);
}
}
};
메모리 디버거 (Valgrind, AddressSanitizer)
/* AddressSanitizer: redzones를 PROT_NONE으로 설정 */
/* 버퍼 오버플로/언더플로를 즉시 탐지 */
void asan_poison_region(void *addr, size_t size)
{
/* redzone 영역을 접근 불가로 설정 */
mprotect(addr, size, PROT_NONE);
/* 이 영역 접근 시 SIGSEGV → 오류 보고 */
}
void asan_unpoison_region(void *addr, size_t size)
{
/* 해제된 메모리를 다시 사용 가능하게 복원 */
mprotect(addr, size, PROT_READ | PROT_WRITE);
}
샌드박스 (seccomp + mprotect)
/* Chrome의 샌드박스: seccomp-BPF로 mprotect 제한 */
struct sock_filter filter[] = {
/* mprotect 시스콜 허용, 단 PROT_EXEC 금지 */
BPF_STMT(BPF_LD | BPF_W | BPF_ABS,
offsetof(struct seccomp_data, nr)),
BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K,
__NR_mprotect, 0, 3),
/* 3번째 인자(prot)에 PROT_EXEC가 있으면 거부 */
BPF_STMT(BPF_LD | BPF_W | BPF_ABS,
offsetof(struct seccomp_data, args[2])),
BPF_JUMP(BPF_JMP | BPF_JSET | BPF_K,
PROT_EXEC, 0, 1),
BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | EACCES),
BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW),
};
- EINVAL: addr이 페이지 정렬되지 않았거나, 잘못된 prot 값
- ENOMEM: 대상 범위에 매핑되지 않은 구간이 포함됨
- EACCES: LSM 정책 위반 (SELinux execmem 등)
- ENOMEM: VMA 분할 시 메모리 부족 (vm_area_struct 할당 실패)
TLB Shootdown과 멀티코어 비용
mprotect는 VMA 플래그만 바꾸는 것으로 끝나지 않습니다. 이미 CPU TLB에 캐시된 변환 정보가 남아 있으면 예전 권한으로 접근할 수 있으므로, 권한 변경 후에는 TLB 무효화가 필요합니다. 단일 코어에서는 로컬 플러시로 끝나지만, 멀티코어에서는 해당 mm를 실행 중인 모든 CPU로 IPI를 보내는 TLB shootdown 경로가 발생합니다.
/* 개념 요약: mprotect 경로의 TLB 플러시 처리 */
int do_mprotect_pkey(...)
{
struct mmu_gather tlb;
tlb_gather_mmu(&tlb, mm);
mprotect_fixup(vma, &prev, start, end, newflags);
tlb_finish_mmu(&tlb, start, end); /* 필요 범위 TLB flush */
return 0;
}
| 환경 | 주요 병목 | 관찰 포인트 |
|---|---|---|
| 단일 스레드/단일 CPU | 시스콜 진입/복귀 | perf stat에서 context-switch 영향 낮음 |
| 다중 스레드/공유 mm | 원격 CPU IPI | IPI 인터럽트와 TLB flush 카운트 증가 |
| 대형 주소 범위 | PTE 순회 + flush 범위 확대 | sys_enter_mprotect 대비 커널 체류 시간 급증 |
THP 및 HugeTLB 매핑에서의 mprotect
4KB 페이지에서는 PTE 단위로 권한이 바뀌지만, THP(예: PMD 크기 2MB)나 HugeTLB(예: 2MB/1GB)에서는 처리 경로가 달라집니다. 일부 경우 mprotect가 huge mapping을 분할(split)해 일반 페이지로 내려오며, 이 과정에서 성능과 메모리 단편화에 영향을 줄 수 있습니다.
MADV_NOHUGEPAGE 또는 명시적 매핑 정책을 검토하세요.
| 매핑 유형 | mprotect 처리 특성 | 실무 영향 |
|---|---|---|
| 일반 4KB 페이지 | PTE 단위 권한 변경 | 세밀 제어 용이, 페이지 수 많으면 순회 비용 증가 |
| THP (PMD) | 부분 변경 시 split 가능 | 일시적/지속적 단편화, TLB 이점 감소 |
| HugeTLB | hugetlb 전용 경로, 제약 엄격 | 권한 변경 정책이 더 제한적일 수 있음 |
userfaultfd Write-Protect와 mprotect 차이
userfaultfd의 write-protect(UFFD-WP)는 페이지 단위 쓰기 보호를 사용자 공간 페이저가
관찰하고 제어할 수 있게 합니다. 겉보기에는 mprotect(PROT_READ)와 유사하지만,
목적과 fault 처리 모델이 다릅니다.
/* userfaultfd write-protect 예시 (요약) */
int uffd = syscall(__NR_userfaultfd, O_CLOEXEC | O_NONBLOCK);
struct uffdio_api api = { .api = UFFD_API };
ioctl(uffd, UFFDIO_API, &api);
struct uffdio_register reg = {
.range = { .start = (unsigned long)addr, .len = len },
.mode = UFFDIO_REGISTER_MODE_WP,
};
ioctl(uffd, UFFDIO_REGISTER, ®);
struct uffdio_writeprotect wp = {
.range = { .start = (unsigned long)addr, .len = len },
.mode = UFFDIO_WRITEPROTECT_MODE_WP,
};
ioctl(uffd, UFFDIO_WRITEPROTECT, &wp);
mprotect 진단: ftrace, perf, BPF
문제가 "권한 전환 실패"인지, "성능 저하"인지 구분해 계측해야 합니다. 커널 함수 추적, 시스콜 이벤트, IPI/TLB 카운터를 함께 보면 병목을 빠르게 좁힐 수 있습니다.
# 1) 시스콜 레벨 지연 추적
perf trace -e mprotect,pkey_mprotect -p <pid>
# 2) 커널 함수 샘플링 (ftrace function_graph)
echo function_graph > /sys/kernel/debug/tracing/current_tracer
echo do_mprotect_pkey > /sys/kernel/debug/tracing/set_graph_function
echo 1 > /sys/kernel/debug/tracing/tracing_on
sleep 3
echo 0 > /sys/kernel/debug/tracing/tracing_on
cat /sys/kernel/debug/tracing/trace
# 3) BPF로 지연 히스토그램 집계 (예: bpftrace)
bpftrace -e 'kprobe:do_mprotect_pkey { @ts[tid] = nsecs; }
kretprobe:do_mprotect_pkey /@ts[tid]/ { @us = hist((nsecs-@ts[tid])/1000); delete(@ts[tid]); }'
- 권한 오류라면 LSM 감사 로그(
dmesg, audit)를 먼저 확인 - 성능 문제라면 mprotect 호출 빈도와 페이지 수를 함께 수집
- JIT 워크로드에서는 THP split, IPI 비중, pkey 사용 가능 여부를 분리 측정
참고자료
- mprotect(2) - Linux man page
- pkey_mprotect(2) - Linux man page
- pkeys(7) - Memory Protection Keys 개요
- Linux Kernel Documentation - Memory Protection Keys
- mm/mprotect.c - 커널 소스 (Bootlin Elixir)
- LWN.net - Memory protection keys
- Intel SDM - Protection Keys (Volume 3, Chapter 4)
- VMA / mmap 심화 -- 가상 메모리 매핑의 전체 구조
- MMU & TLB -- 페이지 테이블과 주소 변환 메커니즘
- LSM / Seccomp -- 보안 모듈의 mprotect 제어
- 메모리 관리 (심화) -- 고급 메모리 관리 주제