mprotect (메모리 보호 변경)
Linux 커널 mprotect 시스콜: PROT_READ/WRITE/EXEC/NONE 보호 플래그, do_mprotect_pkey 커널 구현, VMA 분할/병합, 페이지 테이블(Page Table) 업데이트, Memory Protection Keys(MPK), W^X 정책, SELinux/AppArmor 제약, COW 상호작용, JIT 컴파일러/가드 페이지(Page)/샌드박스(Sandbox) 활용 종합 가이드.
핵심 요약
- 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의 하드웨어 기반 메모리 보호 키로, 시스콜 없이 사용자 공간(User Space)에서 권한을 전환할 수 있습니다.
단계별 이해
- 사용자 공간에서 mprotect() 호출
프로세스(Process)가mprotect(addr, len, prot)를 호출하면 커널로 트랩됩니다. addr은 페이지 정렬이어야 합니다. - 커널이 VMA를 찾고 검증
커널은 주소 범위에 해당하는 VMA를 찾아 보호 변경이 유효한지 검증합니다. LSM(SELinux 등)도 여기서 호출됩니다. - VMA 분할 및 속성 변경
변경 범위가 VMA 일부만 포함하면 VMA를 분할합니다. 그 후vm_flags를 새 보호 플래그로 갱신합니다. - 페이지 테이블 갱신 및 TLB 무효화(Invalidation)
대상 영역의 모든 PTE를 순회하며 권한 비트를 변경하고, TLB를 플러시(Flush)하여 변경 사항을 즉시 반영합니다.
개요
mprotect()는 프로세스의 가상 주소 공간(Address Space)에서 이미 매핑된 메모리 영역의 접근 보호 속성을 변경하는 시스콜입니다.
POSIX 표준에 정의되어 있으며, 메모리 보안의 핵심 메커니즘 중 하나입니다.
일반적인 사용 시나리오는 다음과 같습니다:
- JIT 컴파일러가 코드 생성 후 메모리를 실행 가능하게 전환
- 가드 페이지를
PROT_NONE으로 설정하여 스택 오버플로(Stack Overflow) 탐지 - 읽기 전용으로 전환하여 데이터 무결성(Integrity) 보장
- 메모리 디버거가 접근 패턴을 추적
#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 비트 없음)
*/
protection_map[16] 전체 매핑 테이블
vm_flags의 VM_READ/VM_WRITE/VM_EXEC/VM_SHARED 4비트 조합(총 16가지)은
protection_map[16] 테이블을 통해 아키텍처별 pgprot_t 값으로 변환됩니다.
x86-64에서 각 조합이 PTE 비트에 어떻게 매핑되는지 전체 테이블을 제시합니다.
/* arch/x86/include/asm/pgtable_types.h - x86-64 protection_map */
/* 인덱스: [VM_SHARED][VM_EXEC][VM_WRITE][VM_READ] */
pgprot_t protection_map[16] = {
[0] = PAGE_NONE, /* --- → P=0, NX=1 */
[1] = PAGE_READONLY, /* r-- → P=1, R/W=0, NX=1 */
[2] = PAGE_COPY, /* -w- → P=1, R/W=0, NX=1 (COW) */
[3] = PAGE_COPY, /* rw- → P=1, R/W=0, NX=1 (COW) */
[4] = PAGE_READONLY_EXEC, /* --x → P=1, R/W=0, NX=0 */
[5] = PAGE_READONLY_EXEC, /* r-x → P=1, R/W=0, NX=0 */
[6] = PAGE_COPY_EXEC, /* -wx → P=1, R/W=0, NX=0 (COW) */
[7] = PAGE_COPY_EXEC, /* rwx → P=1, R/W=0, NX=0 (COW) */
[8] = PAGE_NONE, /* ---s → P=0, NX=1 (shared) */
[9] = PAGE_READONLY, /* r--s → P=1, R/W=0, NX=1 */
[10] = PAGE_SHARED, /* -w-s → P=1, R/W=1, NX=1 */
[11] = PAGE_SHARED, /* rw-s → P=1, R/W=1, NX=1 */
[12] = PAGE_READONLY_EXEC, /* --xs → P=1, R/W=0, NX=0 */
[13] = PAGE_READONLY_EXEC, /* r-xs → P=1, R/W=0, NX=0 */
[14] = PAGE_SHARED_EXEC, /* -wxs → P=1, R/W=1, NX=0 */
[15] = PAGE_SHARED_EXEC, /* rwxs → P=1, R/W=1, NX=0 */
};
코드 설명
-
인덱스 2-3행
private 쓰기 매핑(VM_WRITE)은
PAGE_COPY로 매핑됩니다. PTE에서 R/W=0으로 설정하여 첫 쓰기 시 write fault → COW 복사가 발생하게 합니다. -
인덱스 10-11행
shared 쓰기 매핑은
PAGE_SHARED로 R/W=1입니다. COW가 불필요하므로 즉시 쓰기 가능합니다. - NX 비트 VM_EXEC가 없는 모든 조합에서 NX=1(실행 불가)입니다. x86-64에서 NX 비트는 PTE의 63번 비트입니다.
| 인덱스 | vm_flags | Private PTE | Shared PTE | 핵심 차이 |
|---|---|---|---|---|
| 0, 8 | --- | Present=0 (접근 불가) | 가드 페이지 | |
| 1, 9 | r-- | R/W=0, NX=1 | 동일 | 읽기 전용 데이터 |
| 2-3 | -w- / rw- | R/W=0 (COW) | — | 쓰기 시 fault → COW |
| 10-11 | -w-s / rw-s | — | R/W=1 | 즉시 쓰기 가능 |
| 4-5 | --x / r-x | R/W=0, NX=0 | 동일 | 코드 영역 |
| 6-7 | -wx / rwx | R/W=0, NX=0 (COW) | — | W^X 위반 가능 |
| 14-15 | -wxs / rwxs | — | R/W=1, NX=0 | shared W+X (위험) |
VM_MAY* 플래그와 mprotect 권한 한계
mprotect()는 무제한으로 권한을 변경할 수 없습니다. VMA 생성 시 설정되는 VM_MAYREAD,
VM_MAYWRITE, VM_MAYEXEC 플래그가 mprotect로 설정 가능한 최대 권한을 제한합니다.
/* mm/mprotect.c - VM_MAY* 검사 */
static int do_mprotect_pkey(...)
{
/* newflags에서 RWX 부분만 추출 */
unsigned long newflags = calc_vm_prot_bits(prot, pkey);
/* VMA의 기존 비RWX 플래그 보존 */
newflags |= (vma->vm_flags & ~(VM_READ | VM_WRITE | VM_EXEC));
/* 핵심: VM_MAY* 범위를 초과하면 거부 */
if ((newflags & ~(VM_MAYREAD | VM_MAYWRITE | VM_MAYEXEC
| VM_READ | VM_WRITE | VM_EXEC))
!= (vma->vm_flags & ~(VM_MAYREAD | VM_MAYWRITE | VM_MAYEXEC
| VM_READ | VM_WRITE | VM_EXEC)))
return -EACCES;
/*
* VM_READ ⊆ VM_MAYREAD ? OK : EACCES
* VM_WRITE ⊆ VM_MAYWRITE ? OK : EACCES
* VM_EXEC ⊆ VM_MAYEXEC ? OK : EACCES
*/
}
코드 설명
-
VM_MAY* 검사
mmap() 호출 시 지정한 최대 권한이 VM_MAY* 플래그로 기록됩니다. 이후 mprotect()는 이 범위를 초과하는 권한 변경을
-EACCES로 거부합니다.
| VM_MAY* 플래그 | 설정 시점 | mprotect 영향 | 변경 가능 여부 |
|---|---|---|---|
VM_MAYREAD | mmap() 시 | PROT_READ 추가 허용 범위 | 커널 내부만 가능 |
VM_MAYWRITE | mmap() 시 | PROT_WRITE 추가 허용 범위 | 커널 내부만 가능 |
VM_MAYEXEC | mmap() 시 | PROT_EXEC 추가 허용 범위 | 커널 내부만 가능 |
/* VM_MAY* 때문에 mprotect가 실패하는 실전 예시 */
/* 1. 읽기 전용으로 열린 파일의 매핑 */
int fd = open("/etc/passwd", O_RDONLY);
void *p = mmap(NULL, 4096, PROT_READ,
MAP_SHARED, fd, 0);
/* VMA: VM_READ | VM_MAYREAD (VM_MAYWRITE 없음!) */
mprotect(p, 4096, PROT_READ | PROT_WRITE);
/* → -1 (errno=EACCES): VM_WRITE 요청했지만 VM_MAYWRITE 없음 */
/* 2. /proc/PID/smaps에서 VM_MAY* 확인 */
/* VmFlags: rd mr ← VM_READ + VM_MAYREAD만 있음 */
/* mr = VM_MAYREAD, mw = VM_MAYWRITE, me = VM_MAYEXEC */
/proc/PID/smaps의 VmFlags에서 mw(VM_MAYWRITE)가 없는 VMA에는
mprotect로 쓰기 권한을 추가할 수 없습니다. 파일을 O_RDWR로 열거나 MAP_PRIVATE로 매핑해야
VM_MAYWRITE가 설정됩니다.
mprotect 커널 구현
mprotect()의 커널 진입점(Entry Point)은 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 분할/병합과 페이지 테이블 업데이트를 수행하는 핵심 함수입니다.
do_mprotect_pkey() 호출 체인
mprotect(2) 시스콜은 사용자 공간에서 커널까지 다음과 같은 호출 체인을 따릅니다.
시스콜 진입점에서 시작하여 최종적으로 개별 PTE(Page Table Entry)의 권한 비트를 수정하는 pte_modify()까지 이어지는 계층 구조를 이해하면,
mprotect 내부 동작을 정확히 파악할 수 있습니다.
호출 체인의 각 단계는 명확한 역할을 분담합니다:
| 함수 | 소스 파일 | 역할 |
|---|---|---|
SYSCALL_DEFINE3(mprotect) | mm/mprotect.c | 시스콜 진입, do_mprotect_pkey()에 pkey=-1 전달 |
do_mprotect_pkey() | mm/mprotect.c | 주소 검증, VMA 순회, LSM 보안 검사, 루프 내 mprotect_fixup() 호출 |
mprotect_fixup() | mm/mprotect.c | VMA 분할(split_vma)/병합(vma_merge), vm_flags 갱신, change_protection() 호출 |
change_protection() | mm/mprotect.c | TLB gather 초기화, change_protection_range() 호출, TLB 일괄 플러시 |
change_protection_range() | mm/mprotect.c | PGD/P4D/PUD/PMD 4단계 디렉토리 워크 |
change_pte_range() | mm/mprotect.c | PTE 레벨 순회, spinlock 보호 하에 pte_modify()로 권한 비트 교체 |
pte_modify() | arch/x86/include/asm/pgtable.h | PFN 보존, 권한 비트만 newprot로 교체 (아키텍처 종속) |
pkey_mprotect(2) 시스콜은 동일한 do_mprotect_pkey()를 호출하지만, pkey 인자에 유효한 보호 키(0~15)를 전달합니다.
일반 mprotect(2)는 pkey에 -1을 전달하여 기존 보호 키를 유지합니다.
ftrace를 이용하여 mprotect 시스콜의 호출 체인을 실시간으로 추적하는 방법입니다. 각 함수의 호출 순서와 실행 시간을 확인할 수 있습니다.
# ===== ftrace로 mprotect 호출 체인 추적 =====
# 1. ftrace 설정
cd /sys/kernel/debug/tracing
echo 0 > tracing_on
echo function_graph > current_tracer
# 2. mprotect 관련 함수만 필터링
echo 'do_mprotect_pkey' > set_graph_function
echo 'mprotect_fixup change_protection change_pte_range' >> set_graph_function
echo 'split_huge_pmd vma_merge' >> set_graph_function
# 3. 특정 PID만 추적 (선택 사항)
echo $PID > set_ftrace_pid
# 4. 추적 시작
echo > trace # 기존 버퍼 클리어
echo 1 > tracing_on
# 5. 대상 프로그램에서 mprotect 호출
# (다른 터미널에서)
# ./my_program
# 6. 추적 결과 확인
echo 0 > tracing_on
cat trace
설명
function_graph tracer는 함수 호출 관계를 트리 형태로 표시하며, 각 함수의 진입/종료 시간을 마이크로초 단위로 기록합니다. set_graph_function에 do_mprotect_pkey를 설정하면 이 함수와 그 하위 호출 체인 전체가 표시됩니다. 출력에서 change_pte_range가 여러 번 호출되는 것을 확인할 수 있으며, 이는 대상 범위의 PMD 수에 비례합니다.
# ftrace 출력 예시:
# 3) | do_mprotect_pkey() {
# 3) | mprotect_fixup() {
# 3) 0.234 us | vma_merge();
# 3) | change_protection() {
# 3) | change_protection_range() {
# 3) 0.892 us | change_pte_range();
# 3) 1.456 us | }
# 3) 2.103 us | }
# 3) 3.547 us | }
# 3) 4.211 us | }
# bpftrace로 mprotect 호출 인자 추적 (더 상세)
bpftrace -e '
tracepoint:syscalls:sys_enter_mprotect {
printf("pid=%d addr=0x%lx len=%lu prot=%lu\n",
pid, args->start, args->len, args->prot);
}
tracepoint:syscalls:sys_exit_mprotect {
printf("pid=%d ret=%ld\n", pid, args->ret);
}'
# perf로 mprotect 성능 프로파일링
perf stat -e 'syscalls:sys_enter_mprotect' -- ./my_program
perf trace -e mprotect -- ./my_program
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()으로 실제 페이지 테이블 엔트리를 순회하며 권한 비트를 변경합니다.
vm_area_struct의 mprotect 관련 필드
mprotect 동작을 이해하려면 struct vm_area_struct에서 보호 속성과 관련된 핵심 필드를 파악해야 합니다.
mprotect_fixup()은 이 필드들을 직접 수정하고, change_protection()은 이 필드 값을 기반으로 PTE를 갱신합니다.
/* include/linux/mm_types.h - mprotect 관련 핵심 필드 (v6.x 기준) */
struct vm_area_struct {
unsigned long vm_start; /* VMA 시작 가상 주소 (페이지 정렬) */
unsigned long vm_end; /* VMA 끝 주소 (exclusive, 페이지 정렬) */
/*
* vm_flags: VMA 보호 속성 + 매핑 특성
* mprotect()가 직접 변경하는 핵심 필드
* VM_READ | VM_WRITE | VM_EXEC | VM_SHARED 등
*/
vm_flags_t vm_flags; /* 보호 플래그 (mprotect 변경 대상) */
/*
* vm_page_prot: vm_flags를 아키텍처별 PTE 비트로 변환한 값
* pgprot_t는 x86에서 _PAGE_RW, _PAGE_NX 등을 포함
* mprotect_fixup()이 vma_set_page_prot()로 재계산
*/
pgprot_t vm_page_prot; /* PTE 보호 비트 (아키텍처 종속) */
struct file *vm_file; /* 매핑된 파일 (mprotect 시 LSM 검사 대상) */
const struct vm_operations_struct *vm_ops; /* VMA 연산 테이블 */
/*
* vm_pgoff: 파일 매핑 시 오프셋
* mprotect는 이 값을 변경하지 않음 (보호만 변경)
*/
unsigned long vm_pgoff; /* 파일 오프셋 (PAGE_SIZE 단위) */
/* anon_vma: anonymous 매핑의 역방향 매핑 구조
* COW 발생 시 mprotect와 상호작용 */
struct anon_vma *anon_vma; /* 익명 VMA 역방향 매핑 */
};
코드 설명
-
vm_flags
mprotect의 핵심 변경 대상입니다.
VM_READ(0x01),VM_WRITE(0x02),VM_EXEC(0x04) 비트를 조합하여 접근 권한을 표현합니다.calc_vm_prot_bits()가 사용자 PROT 플래그를 이 값으로 변환합니다. -
vm_page_prot
vm_flags를 하드웨어 PTE 비트로 변환한 캐시 값입니다. x86에서VM_WRITE→_PAGE_RW,VM_EXEC→_PAGE_NX해제로 매핑됩니다.mprotect_fixup()내부에서vma_set_page_prot()를 호출하여 재계산합니다. -
vm_file
파일 매핑인 경우 LSM(
security_file_mprotect())이 이 필드를 참조하여 보호 변경을 허용할지 결정합니다. SELinux는 파일 보안 컨텍스트(Context)를 검사합니다. -
anon_vma
COW(Copy-On-Write) 상호작용에 관여합니다.
mprotect(PROT_WRITE)로 쓰기 권한을 부여하더라도, COW 페이지는 실제 쓰기 시 폴트(Fault)가 발생하여 복사됩니다.
VMA 필드를 커널 모듈에서 직접 조회하여 mprotect 동작을 분석하는 예시입니다. /proc/[pid]/maps의 출력과 대응하는 커널 자료 구조를 확인할 수 있습니다.
/* vma_inspect.c — VMA 필드를 커널 모듈에서 조회하는 예시 */
#include <linux/module.h>
#include <linux/sched.h>
#include <linux/mm.h>
#include <linux/mm_types.h>
#include <linux/proc_fs.h>
static int target_pid = 0;
module_param(target_pid, int, 0644);
static void dump_vma_flags(unsigned long flags)
{
pr_info(" vm_flags: 0x%lx [", flags);
if (flags & VM_READ) pr_cont("READ ");
if (flags & VM_WRITE) pr_cont("WRITE ");
if (flags & VM_EXEC) pr_cont("EXEC ");
if (flags & VM_SHARED) pr_cont("SHARED ");
if (flags & VM_MAYREAD) pr_cont("MAYREAD ");
if (flags & VM_MAYWRITE)pr_cont("MAYWRITE ");
if (flags & VM_MAYEXEC) pr_cont("MAYEXEC ");
if (flags & VM_HUGETLB) pr_cont("HUGETLB ");
pr_cont("]\n");
}
static int __init vma_inspect_init(void)
{
struct task_struct *task;
struct mm_struct *mm;
struct vm_area_struct *vma;
struct vma_iterator vmi;
int count = 0;
rcu_read_lock();
task = find_task_by_vpid(target_pid);
if (!task) {
rcu_read_unlock();
pr_err("PID %d를 찾을 수 없습니다\n", target_pid);
return -ESRCH;
}
get_task_struct(task);
rcu_read_unlock();
mm = get_task_mm(task);
if (!mm) {
put_task_struct(task);
return -EINVAL;
}
mmap_read_lock(mm);
vma_iter_init(&vmi, mm, 0);
for_each_vma(vmi, vma) {
pr_info("VMA [0x%lx - 0x%lx] (%lu KB)\n",
vma->vm_start, vma->vm_end,
(vma->vm_end - vma->vm_start) >> 10);
dump_vma_flags(vma->vm_flags);
pr_info(" vm_page_prot: 0x%lx, file: %s\n",
pgprot_val(vma->vm_page_prot),
vma->vm_file ? file_dentry(vma->vm_file)->d_name.name
: "(anonymous)");
if (++count >= 20) break; /* 처음 20개만 출력 */
}
mmap_read_unlock(mm);
mmput(mm);
put_task_struct(task);
return 0;
}
static void __exit vma_inspect_exit(void) {}
module_init(vma_inspect_init);
module_exit(vma_inspect_exit);
MODULE_LICENSE("GPL");
설명
for_each_vma() 매크로는 maple tree 기반 VMA 이터레이터를 사용하여 프로세스의 모든 VMA를 순회합니다. 각 VMA의 vm_flags를 비트 단위로 분석하면 /proc/[pid]/maps의 r-xp 같은 권한 표시가 어떤 플래그 조합인지 알 수 있습니다. mmap_read_lock()으로 mm 구조체를 읽기 잠금한 상태에서 순회해야 경합 조건을 방지할 수 있습니다. vm_page_prot의 raw 값은 아키텍처별 PTE 비트를 포함하며, x86에서 _PAGE_RW(bit 1), _PAGE_NX(bit 63) 등이 설정됩니다.
# 모듈 로드 (대상 PID의 VMA 정보 출력)
insmod vma_inspect.ko target_pid=$$
dmesg | tail -40
# /proc/[pid]/maps와 대조 (유저스페이스에서 확인 가능한 정보)
cat /proc/$$/maps | head -20
# 출력: 주소범위 권한 오프셋 장치 inode 경로
# 5555555000-5555556000 r-xp 00001000 fd:01 1234 /usr/bin/bash
# vm_flags 비트 매핑 확인
# r-xp → VM_READ | VM_EXEC (0x05) + VM_MAYREAD | VM_MAYWRITE | VM_MAYEXEC
# rw-p → VM_READ | VM_WRITE (0x03)
mprotect_fixup()이 vm_flags를 갱신하는 핵심 흐름은 다음과 같습니다:
/* mm/mprotect.c - mprotect_fixup() 핵심 로직 (단순화) */
static int mprotect_fixup(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;
/* 1. 플래그 변경이 없으면 즉시 반환 */
if (newflags == oldflags) {
*pprev = vma;
return 0;
}
/* 2. VMA 분할/병합 */
vma = vma_modify_flags(vma, pprev, start, end, newflags);
/* 3. vm_flags 갱신 + vm_page_prot 재계산 */
vm_flags_reset(vma, newflags);
/* dirty_accountable: 쓰기 가능→soft dirty 추적 */
newprot = vm_get_page_prot(newflags);
/* 4. 페이지 테이블의 실제 PTE 권한 갱신 */
change_protection(&tlb, vma, start, end, newprot, cp_flags);
return 0;
}
코드 설명
- 10-14행 보호 플래그가 실제로 변경되지 않으면 VMA 분할이나 페이지 테이블 순회 없이 즉시 반환합니다. 불필요한 TLB 플러시를 방지하는 최적화입니다.
-
17행
vma_modify_flags()는 내부적으로vma_merge()를 시도하고, 실패하면split_vma()로 VMA를 분할합니다. 변경 범위가 VMA 일부만 포함할 때 분할이 발생합니다. -
20행
vm_flags_reset()으로 VMA의vm_flags를 새 보호 플래그로 갱신합니다. 이후vm_get_page_prot()로 아키텍처별 PTE 보호 비트를 계산합니다. -
25행
change_protection()이 실제 페이지 테이블을 순회하며 모든 PTE의 권한 비트를 갱신합니다. 이 호출이 가장 비용이 큰 단계입니다.
페이지 테이블 업데이트
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 플러시를 일괄 처리하기 위한 구조체(Struct)를 초기화합니다. 개별 플러시보다 효율적입니다. -
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(): 원자적(Atomic)으로 PTE를 교체하고 TLB 플러시 대상에 추가합니다.
PROT_NONE 내부 PTE 인코딩
PROT_NONE은 특수한 보호 속성입니다. "접근 불가"를 의미하지만, 커널은 해당 페이지가 여전히 매핑되어 있음을
구분해야 합니다(진짜 "미할당"과 구별). x86-64에서 PROT_NONE 페이지의 PTE 인코딩은 두 가지 방식으로 구현됩니다.
/* arch/x86/include/asm/pgtable.h - PROT_NONE PTE 판별 */
/* 핵심: pte_present()는 Present OR PROTNONE을 모두 참으로 처리 */
static inline int pte_present(pte_t a)
{
return pte_flags(a) & (_PAGE_PRESENT | _PAGE_PROTNONE);
}
/* 진짜 비어있는(미할당) PTE 검사 */
static inline int pte_none(pte_t pte)
{
return !(pte_flags(pte) & ~_PAGE_KNL_ERRATUM_MASK);
}
/*
* _PAGE_PROTNONE 비트 위치: x86-64에서 Global 비트(bit 8)를 재활용
* - 하드웨어는 Present=0이면 다른 비트를 무시하므로
* 소프트웨어가 자유롭게 사용 가능
* - NUMA 밸런싱도 이 메커니즘을 재활용
*/
#define _PAGE_PROTNONE (_AT(pteval_t, 1) << _PAGE_BIT_PROTNONE)
코드 설명
-
pte_present()
PROT_NONE 페이지는 하드웨어 Present=0이므로 CPU가 접근하면 page fault가 발생합니다. 하지만 커널의
pte_present()는 _PAGE_PROTNONE 비트도 검사하여 "매핑은 존재하지만 접근 금지"로 인식합니다. - _PAGE_PROTNONE 비트 x86-64에서 _PAGE_PROTNONE은 Global 비트(bit 8)를 재활용합니다. Present=0일 때 하드웨어는 나머지 비트를 무시하므로, 소프트웨어가 마커로 사용할 수 있습니다.
PROT_NONE은 다음과 같은 용도로 활용됩니다:
| 용도 | 메커니즘 | 구현체 |
|---|---|---|
| 가드 페이지 | 접근 시 SIGSEGV → 스택 오버플로 탐지 | glibc pthread, 커널 스택 |
| NUMA 밸런싱 | PROT_NONE → 접근 fault → 올바른 노드로 마이그레이션 | AutoNUMA (change_prot_numa()) |
| 메모리 디버깅 | 해제된 메모리를 PROT_NONE → use-after-free 탐지 | Valgrind, ASan |
| Lazy 매핑 해제 | unmap 대신 PROT_NONE → 재사용 시 복원 비용 절감 | 커스텀 메모리 할당기 |
change_pte_range() 함수 구현 분석
change_pte_range()는 mprotect 호출 체인의 최하위 함수로, 실제 PTE 엔트리를 하나씩 순회하며 권한 비트를 수정합니다.
이 함수는 swap 엔트리(Swap Entry), NUMA 밸런싱(Balancing), userfaultfd write-protect,
soft-dirty 추적 등 다양한 조건을 처리하는 복잡한 로직을 포함합니다.
아래는 v6.x 커널 기준으로 핵심 경로를 단순화한 코드입니다.
/* mm/mprotect.c - change_pte_range() 분석 (v6.x 단순화) */
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;
int target_node = NUMA_NO_NODE;
bool uffd_wp = cp_flags & MM_CP_UFFD_WP;
bool uffd_wp_resolve = cp_flags & MM_CP_UFFD_WP_RESOLVE;
/* PTE 테이블 매핑 + spinlock 획득 */
pte = pte_offset_map_lock(vma->vm_mm, pmd, addr, &ptl);
if (!pte)
return 0;
/* 아키텍처별 TLB 일괄 처리 시작 */
arch_enter_lazy_mmu_mode();
for (; addr < end; pte++, addr += PAGE_SIZE) {
oldpte = ptep_get(pte);
if (pte_present(oldpte)) {
/* ── 1단계: present PTE 처리 ── */
pte_t ptent;
/* NUMA 밸런싱: 타겟 노드가 아니면 PROT_NONE으로 설정 */
if (cp_flags & MM_CP_PROT_NUMA) {
struct folio *folio = vm_normal_folio(vma, addr, oldpte);
if (!folio || folio_is_zone_device(folio))
continue;
if (folio_nid(folio) == target_node)
continue;
}
/* 기존 PTE에서 권한 비트만 교체 (PFN 유지) */
oldpte = ptep_modify_prot_start(vma, addr, pte);
ptent = pte_modify(oldpte, newprot);
/* soft-dirty 추적 유지 */
if (pte_soft_dirty(oldpte))
ptent = pte_mksoft_dirty(ptent);
/* userfaultfd write-protect 적용/해제 */
if (uffd_wp)
ptent = pte_mkuffd_wp(ptent);
else if (uffd_wp_resolve)
ptent = pte_clear_uffd_wp(ptent);
/* 원자적 PTE 교체 완료 */
ptep_modify_prot_commit(vma, addr, pte, oldpte, ptent);
pages++;
} else if (is_swap_pte(oldpte)) {
/* ── 2단계: swap PTE 처리 ── */
swp_entry_t entry = pte_to_swp_entry(oldpte);
/* swap 엔트리도 soft-dirty / uffd-wp 비트 갱신 */
if (uffd_wp && !pte_swp_uffd_wp(oldpte))
ptent = pte_swp_mkuffd_wp(oldpte);
else if (uffd_wp_resolve && pte_swp_uffd_wp(oldpte))
ptent = pte_swp_clear_uffd_wp(oldpte);
set_pte_at(vma->vm_mm, addr, pte, ptent);
pages++;
}
}
arch_leave_lazy_mmu_mode();
pte_unmap_unlock(pte - 1, ptl);
return pages;
}
코드 설명
-
15-17행
pte_offset_map_lock()은 PMD가 가리키는 PTE 테이블을 커널 가상 주소(Kernel Virtual Address)로 매핑하고, 해당 PTE 테이블의 spinlock을 획득합니다. 이 락은 동일 PTE 테이블을 수정하는 다른 CPU와의 경합을 방지합니다. -
20행
arch_enter_lazy_mmu_mode(): x86 paravirt 환경(Xen 등)에서 TLB 플러시를 지연 처리합니다. 여러 PTE를 수정한 후 한 번에 플러시하여 hypervisor 호출 비용을 줄입니다. -
30-37행
NUMA 밸런싱(
MM_CP_PROT_NUMA): 자동 NUMA 밸런싱은 mprotect 코드를 재사용하여 PTE를 PROT_NONE으로 설정합니다. 이미 올바른 NUMA 노드(Node)에 있는 페이지는 건너뛰는 최적화를 적용합니다. -
40-41행
ptep_modify_prot_start()/ptep_modify_prot_commit()쌍은 원자적(Atomic) PTE 수정을 보장합니다. start에서 기존 PTE를 읽고 무효화한 후, commit에서 새 PTE를 기록합니다. -
44-45행
soft-dirty 비트 보존: 이전 PTE에 soft-dirty가 설정되어 있으면 새 PTE에도 유지합니다. 이 비트는
/proc/PID/pagemap을 통한 메모리 변경 추적에 사용됩니다. - 48-51행 userfaultfd write-protect: QEMU 라이브 마이그레이션(Migration) 등에서 사용하는 userfaultfd-wp 비트를 PTE에 설정하거나 해제합니다. mprotect의 W 비트와는 별도로 관리됩니다.
- 56-66행 swap PTE 처리: 메모리에서 스왑 아웃된 페이지도 보호 비트(soft-dirty, uffd-wp)를 갱신해야 합니다. 스왑 PTE는 PFN 대신 swap 엔트리를 인코딩하고 있어 별도 처리가 필요합니다.
change_pte_range()는 대상 범위의 모든 PTE를 순회합니다.
1GB 영역에 대한 mprotect 호출은 약 262,144개의 PTE(4KB 페이지 기준)를 순회해야 하며,
이 과정에서 PTE 테이블 spinlock을 획득/해제하고 TLB 플러시가 필요합니다.
대규모 영역에는 HugeTLB 매핑을 사용하면 PTE 수가 512배 줄어들어 mprotect 비용이 크게 감소합니다.
페이지 테이블 워크 코드 분석
change_protection_range()는 4/5단계 페이지 테이블을 최상위(PGD)부터 최하위(PTE)까지 계층적으로 순회합니다.
각 레벨에서 해당 인덱스의 엔트리가 유효한지(present) 확인하고, 다음 레벨의 테이블 주소를 추출하여 한 단계 아래로 내려갑니다.
리눅스 커널은 5단계 페이지 테이블(PGD → P4D → PUD → PMD → PTE)을 지원하지만,
실제 하드웨어가 4단계만 사용하면 P4D는 폴딩(Folding)됩니다.
/* mm/mprotect.c - 페이지 테이블 워크 (단순화) */
/* 최상위: PGD → P4D 순회 */
static unsigned long change_protection_range(
struct mmu_gather *tlb,
struct vm_area_struct *vma,
unsigned long addr, unsigned long end,
pgprot_t newprot, unsigned long cp_flags)
{
pgd_t *pgd;
unsigned long next, pages = 0;
pgd = pgd_offset(vma->vm_mm, addr);
do {
next = pgd_addr_end(addr, end);
if (pgd_none_or_clear_bad(pgd))
continue;
pages += change_p4d_range(tlb, vma, pgd,
addr, next, newprot, cp_flags);
} while (pgd++, addr = next, addr != end);
return pages;
}
/* PMD 레벨: Huge Page 검사 후 PTE로 하강 */
static unsigned long change_pmd_range(
struct mmu_gather *tlb,
struct vm_area_struct *vma, pud_t *pud,
unsigned long addr, unsigned long end,
pgprot_t newprot, unsigned long cp_flags)
{
pmd_t *pmd;
unsigned long next, pages = 0;
pmd = pmd_offset(pud, addr);
do {
next = pmd_addr_end(addr, end);
/* THP(Transparent Huge Page) 2MB 매핑 검사 */
if (is_swap_pmd(*pmd) || pmd_trans_huge(*pmd)
|| pmd_devmap(*pmd)) {
/* 2MB 단위로 권한 변경 (PTE 순회 불필요) */
if (pmd_trans_huge(*pmd)) {
pages += change_huge_pmd(tlb, vma, pmd,
addr, newprot, cp_flags);
continue;
}
}
if (pmd_none_or_clear_bad(pmd))
continue;
/* PTE 레벨로 하강 */
pages += change_pte_range(vma, pmd, addr, next,
newprot, cp_flags);
} while (pmd++, addr = next, addr != end);
return pages;
}
코드 설명
-
13행
pgd_offset(mm, addr): 프로세스의 PGD 테이블에서 가상 주소에 해당하는 엔트리를 찾습니다. x86-64에서 PGD 인덱스는 가상 주소의 비트 [47:39] (4단계) 또는 [56:48] (5단계)입니다. -
15행
pgd_addr_end(): 현재 PGD 엔트리가 커버하는 주소 범위의 끝을 계산합니다. 한 PGD 엔트리는 512GB(4단계) 범위를 관리합니다. -
16-17행
pgd_none_or_clear_bad(): PGD 엔트리가 비어있거나(none) 손상된(bad) 경우 건너뜁니다. 아직 할당되지 않은 가상 주소 범위입니다. -
40-49행
THP(Transparent Huge Page) 처리: PMD 레벨에서 2MB 거대 페이지(Huge Page)가 감지되면
change_huge_pmd()를 호출합니다. PTE 테이블이 없으므로 PMD 엔트리 하나만 수정하면 됩니다. mprotect 성능이 512배 향상됩니다. - 51-52행 PMD가 비어있으면 해당 2MB 범위에 매핑된 페이지가 없으므로 건너뜁니다. demand paging으로 아직 할당되지 않은 영역입니다.
-
55-56행
일반 4KB 페이지인 경우
change_pte_range()로 하강하여 개별 PTE를 순회합니다.
change_huge_pmd()가 PMD 엔트리 하나만 수정합니다.
PTE 512개를 순회할 필요가 없으므로 mprotect 비용이 크게 감소합니다.
마찬가지로 PUD 레벨에서 1GB HugeTLB가 감지되면 change_huge_pud()로 단일 엔트리만 처리합니다.
대규모 메모리 영역에 mprotect를 빈번히 호출하는 워크로드(JIT 컴파일러, 데이터베이스 버퍼 풀 등)에서는
Huge Page 사용이 성능에 큰 영향을 미칩니다.
pkey와 MPK (Memory Protection Keys)
Intel의 Memory Protection Keys(MPK)는 하드웨어 기반의 고속 메모리 보호 메커니즘입니다.
각 페이지에 4비트 보호 키(pkey, 0~15)를 할당하고, 사용자 공간에서 PKRU(Protection Key Rights for User pages) 레지스터(Register)를 직접 수정하여
시스콜 없이 접근 권한을 전환할 수 있습니다.
/* 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권한이 필요합니다. 공유 라이브러리(Shared Library)의 텍스트 릴로케이션에 해당합니다. -
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 복사 → 쓰기 */
}
mprotect와 시그널 핸들링 (SIGSEGV 복구)
mprotect로 보호된 메모리에 접근하면 SIGSEGV가 발생합니다.
시그널 핸들러에서 접근 위반 원인을 분석하고, mprotect로 권한을 복원하여 프로그램을 계속 실행하는
기법은 가비지 컬렉터(GC), 메모리 디버거, 체크포인팅 시스템 등에서 널리 사용됩니다.
#include <signal.h>
#include <sys/mman.h>
#include <stdint.h>
/* 보호된 메모리 영역 정보 */
static void *tracked_region;
static size_t tracked_size;
static volatile int write_detected;
/* SIGSEGV 핸들러: 쓰기 접근 추적 + 권한 복구 */
static void segv_handler(int sig, siginfo_t *info, void *ucontext)
{
void *fault_addr = info->si_addr;
/* 우리가 추적 중인 영역인지 확인 */
if (info->si_code == SEGV_ACCERR &&
fault_addr >= tracked_region &&
fault_addr < tracked_region + tracked_size) {
/* 해당 페이지를 dirty로 마킹 */
uintptr_t page = (uintptr_t)fault_addr & ~(4095UL);
write_detected++;
/* 권한 복원: 쓰기 허용 → 핸들러 반환 후 명령 재실행 */
mprotect((void *)page, 4096, PROT_READ | PROT_WRITE);
return; /* 폴트 명령으로 복귀 → 정상 실행 */
}
/* 추적 영역이 아니면 기본 SIGSEGV 처리 */
_exit(139); /* 128 + SIGSEGV(11) */
}
/* 사용 예시: 쓰기 추적 설정 */
void setup_write_tracking(void *region, size_t size)
{
tracked_region = region;
tracked_size = size;
/* SA_SIGINFO로 siginfo_t 수신 활성화 */
struct sigaction sa = {
.sa_sigaction = segv_handler,
.sa_flags = SA_SIGINFO,
};
sigemptyset(&sa.sa_mask);
sigaction(SIGSEGV, &sa, NULL);
/* 읽기 전용으로 설정 → 쓰기 시 SIGSEGV 발생 */
mprotect(region, size, PROT_READ);
}
코드 설명
-
si_code 확인
SEGV_ACCERR은 권한 위반(매핑은 존재하지만 접근 불가)을 의미합니다.SEGV_MAPERR는 매핑 자체가 없는 경우로 구분해야 합니다. - 페이지 정렬 fault_addr을 PAGE_SIZE 경계로 내림하여 해당 페이지의 시작 주소를 계산합니다. mprotect는 페이지 정렬된 주소만 받습니다.
- 핸들러 반환 mprotect로 권한을 복원한 후 핸들러에서 return하면, 커널은 폴트를 발생시킨 원래 명령어를 재실행합니다. 이번에는 권한이 복원되어 정상 실행됩니다.
-
SA_SIGINFO
SA_SIGINFO플래그는 핸들러에siginfo_t를 전달합니다. 이를 통해si_addr(폴트 주소)과si_code(폴트 유형)를 알 수 있습니다.
이 패턴은 다음 시스템에서 실제로 사용됩니다:
| 시스템 | 용도 | 동작 방식 |
|---|---|---|
| Java GC (ZGC, Shenandoah) | 동시 GC의 읽기 배리어(Read Barrier) | PROT_NONE 영역 접근 → SIGSEGV → GC 배리어 실행 → 복원 |
| Boehm GC | 쓰기 배리어(Write Barrier) | PROT_READ → 쓰기 폴트 → dirty 페이지 기록 → PROT_READ|PROT_WRITE |
| CRIU (체크포인트/복원) | 증분 체크포인트 | 스냅샷 후 PROT_READ → 변경된 페이지만 재기록 |
| 메모리 디버거 | 접근 패턴 추적 | PROT_NONE → 모든 접근 기록 → 권한 복원 |
- 시그널 핸들러에서
mprotect()는 async-signal-safe가 아닙니다 (POSIX 표준). 하지만 Linux에서는 실제로 안전하게 동작합니다. - 핸들러에서
si_code를 반드시 확인하세요.SEGV_MAPERR(미매핑)에 대해 mprotect를 호출하면 실패합니다. - 핸들러 내부에서 시그널이 중첩(nested)되면 대체 스택(
SA_ONSTACK+sigaltstack())이 필요합니다. - 멀티스레드(Multithread) 환경에서는 동일 페이지에 대한 동시 핸들러 실행에 주의해야 합니다.
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 레지스터에 초기 권한을 설정합니다.
아래는 유저스페이스에서 MPK(Memory Protection Keys)를 활용하는 전체 흐름 예시입니다. pkey_alloc()으로 키를 할당하고, pkey_mprotect()로 메모리 영역에 키를 바인딩한 뒤, pkey_set()(WRPKRU 명령)으로 실시간 접근 권한을 전환합니다.
/* pkey_demo.c — MPK를 이용한 메모리 보호 전체 흐름 */
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <signal.h>
#include <unistd.h>
#include <errno.h>
/* PKRU 레지스터 직접 조작 (glibc에 pkey_set이 없을 때) */
static inline void wrpkru(unsigned int pkru)
{
unsigned int ecx = 0, edx = 0;
asm volatile(".byte 0x0f,0x01,0xef\n\t"
: : "a"(pkru), "c"(ecx), "d"(edx));
}
static inline unsigned int rdpkru(void)
{
unsigned int ecx = 0, pkru;
asm volatile(".byte 0x0f,0x01,0xee\n\t"
: "=a"(pkru) : "c"(ecx) : "edx");
return pkru;
}
/* pkey 비트 위치: 각 키는 2비트 (AD=접근금지, WD=쓰기금지) */
#define PKEY_DISABLE_ACCESS 0x1
#define PKEY_DISABLE_WRITE 0x2
static void pkey_set(int pkey, unsigned int rights)
{
unsigned int pkru = rdpkru();
pkru &= ~(0x3 << (2 * pkey)); /* 기존 비트 클리어 */
pkru |= (rights << (2 * pkey)); /* 새 권한 설정 */
wrpkru(pkru);
}
static void sigsegv_handler(int sig, siginfo_t *si, void *unused)
{
printf("SIGSEGV: addr=%p, si_code=%d (PKE=%d)\n",
si->si_addr, si->si_code,
si->si_code == 5); /* SEGV_PKUERR=5 */
_exit(1);
}
int main(void)
{
struct sigaction sa = { .sa_sigaction = sigsegv_handler,
.sa_flags = SA_SIGINFO };
sigaction(SIGSEGV, &sa, NULL);
/* 1. 메모리 영역 할당 */
size_t size = 4096;
void *secret = mmap(NULL, size, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
strcpy(secret, "sensitive data");
/* 2. 보호 키 할당 (키 0은 기본, 1~15 사용 가능) */
int pkey = pkey_alloc(0, 0);
if (pkey < 0) {
perror("pkey_alloc (MPK 미지원?)");
return 1;
}
printf("할당된 pkey=%d\n", pkey);
/* 3. pkey_mprotect로 메모리에 키 바인딩 */
if (pkey_mprotect(secret, size, PROT_READ | PROT_WRITE, pkey)) {
perror("pkey_mprotect");
return 1;
}
/* 4. 정상 접근 (키 권한: 읽기+쓰기 허용) */
printf("정상 읽기: %s\n", (char *)secret);
/* 5. 쓰기 금지 설정 (PKRU 변경, 시스콜 불필요) */
pkey_set(pkey, PKEY_DISABLE_WRITE);
printf("읽기 전용 모드 (PKRU=0x%08x)\n", rdpkru());
printf("읽기 성공: %s\n", (char *)secret);
/* 6. 접근 완전 차단 */
pkey_set(pkey, PKEY_DISABLE_ACCESS);
printf("접근 차단 모드 → 다음 읽기에서 SIGSEGV 발생\n");
printf("읽기 시도: %s\n", (char *)secret); /* SIGSEGV! */
pkey_free(pkey);
munmap(secret, size);
return 0;
}
설명
MPK의 핵심 장점은 pkey_set()(WRPKRU 명령)이 시스콜 없이 유저스페이스에서 직접 실행된다는 점입니다. 따라서 mprotect()보다 수천 배 빠르게 권한을 전환할 수 있습니다. pkey_alloc()은 프로세스당 최대 15개(키 0은 기본)의 보호 키를 할당할 수 있으며, pkey_mprotect()는 PTE의 59~62비트에 키 번호를 기록합니다. PKRU 레지스터 변경은 스레드 로컬이므로 다른 스레드에 영향을 주지 않습니다.
# MPK 하드웨어 지원 확인
grep -o pku /proc/cpuinfo | head -1 # "pku" 출력 시 지원
# 빌드 및 실행
gcc -O2 -o pkey_demo pkey_demo.c
./pkey_demo
# 출력 예시:
# 할당된 pkey=1
# 정상 읽기: sensitive data
# 읽기 전용 모드 (PKRU=0x55555554)
# 읽기 성공: sensitive data
# 접근 차단 모드 → 다음 읽기에서 SIGSEGV 발생
# SIGSEGV: addr=0x7f..., si_code=5 (PKE=1)
커널 설정과 보안 강화
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 포인터 역참조(Dereference) 방지 최소 주소 |
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) 컴파일러는 런타임에 기계어(Machine Code) 코드를 생성하고 실행합니다. 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)를 사용하여 시스콜 오버헤드(Overhead)를 회피하세요.
- 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에 캐시(Cache)된 변환 정보가 남아 있으면 예전 권한으로 접근할 수 있으므로, 권한 변경 후에는 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;
}
| 환경 | 주요 병목(Bottleneck) | 관찰 포인트 |
|---|---|---|
| 단일 스레드(Thread)/단일 CPU | 시스콜 진입/복귀 | perf stat에서 context-switch 영향 낮음 |
| 다중 스레드/공유 mm | 원격 CPU IPI | IPI 인터럽트(Interrupt)와 TLB flush 카운트 증가 |
| 대형 주소 범위 | PTE 순회 + flush 범위 확대 | sys_enter_mprotect 대비 커널 체류 시간 급증 |
THP 및 HugeTLB 매핑에서의 mprotect
4KB 페이지에서는 PTE 단위로 권한이 바뀌지만, THP(예: PMD 크기 2MB)나 HugeTLB(예: 2MB/1GB)에서는 처리 경로가 달라집니다. 일부 경우 mprotect가 huge mapping을 분할(split)해 일반 페이지로 내려오며, 이 과정에서 성능과 메모리 단편화(Fragmentation)에 영향을 줄 수 있습니다.
MADV_NOHUGEPAGE 또는 명시적 매핑 정책을 검토하세요.
| 매핑 유형 | mprotect 처리 특성 | 실무 영향 |
|---|---|---|
| 일반 4KB 페이지 | PTE 단위 권한 변경 | 세밀 제어 용이, 페이지 수 많으면 순회 비용 증가 |
| THP (PMD) | 부분 변경 시 split 가능 | 일시적/지속적 단편화, TLB 이점 감소 |
| HugeTLB | hugetlb 전용 경로, 제약 엄격 | 권한 변경 정책이 더 제한적일 수 있음 |
THP와 mprotect의 상호작용을 직접 확인하는 코드입니다. THP가 활성화된 상태에서 부분 범위 mprotect를 호출하면 split이 발생하는 과정을 추적합니다.
/* thp_mprotect_demo.c — THP split 발생 조건 확인 */
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <unistd.h>
#define THP_SIZE (2UL * 1024 * 1024) /* 2MB = PMD 크기 */
static void show_smaps(void *addr, const char *label)
{
char cmd[256];
printf("\n=== %s (addr=%p) ===\n", label, addr);
snprintf(cmd, sizeof(cmd),
"grep -A5 '%lx' /proc/%d/smaps | "
"grep -E 'AnonHugePages|THPeligible|VmFlags'",
(unsigned long)addr, getpid());
system(cmd);
}
int main(void)
{
/* 1. 2MB 정렬 매핑 (THP 친화적) */
void *area = mmap(NULL, THP_SIZE * 4,
PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (area == MAP_FAILED) { perror("mmap"); return 1; }
/* THP 요청 힌트 */
madvise(area, THP_SIZE * 4, MADV_HUGEPAGE);
/* 2. 전체 영역 터치 → THP 할당 유도 */
memset(area, 0xAA, THP_SIZE * 4);
show_smaps(area, "THP 할당 후");
/* 3. 전체 2MB 범위에 mprotect → split 미발생 */
mprotect(area, THP_SIZE, PROT_READ);
show_smaps(area, "전체 THP 범위 mprotect(RD)");
/* 4. 부분 범위 mprotect → split_huge_pmd() 발생! */
void *mid = (char *)area + THP_SIZE + (64 * 1024);
mprotect(mid, 64 * 1024, PROT_READ | PROT_EXEC);
show_smaps((char *)area + THP_SIZE,
"부분 범위 mprotect(RX) → THP split");
/* 5. split 발생 통계 확인 */
printf("\n=== THP split 통계 ===\n");
system("grep thp_split /proc/vmstat");
munmap(area, THP_SIZE * 4);
return 0;
}
설명
THP split은 mprotect 범위가 huge page 경계(2MB)와 정확히 일치하지 않을 때 발생합니다. 위 코드에서 3단계의 전체 2MB 범위 mprotect는 PMD 엔트리의 권한만 변경하므로 split이 발생하지 않습니다. 그러나 4단계의 부분 범위(64KB) mprotect는 split_huge_pmd()를 호출하여 하나의 PMD 엔트리를 512개의 PTE로 분해합니다. /proc/vmstat의 thp_split_pmd 카운터로 split 횟수를 확인할 수 있습니다.
# THP split 발생 추적 (ftrace)
echo 1 > /sys/kernel/debug/tracing/events/huge_memory/mm_khugepaged_scan_pmd/enable
echo 1 > /sys/kernel/debug/tracing/events/thp/thp_split_pmd/enable
# 프로그램 실행 후 trace 확인
./thp_mprotect_demo
cat /sys/kernel/debug/tracing/trace | grep thp_split
# JIT 워크로드에서 THP 비활성화 (split 반복 방지)
madvise(jit_area, jit_size, MADV_NOHUGEPAGE);
# 또는 시스템 전체 비활성화
echo never > /sys/kernel/mm/transparent_hugepage/enabled
userfaultfd Write-Protect와 mprotect 차이
userfaultfd의 write-protect(UFFD-WP)는 페이지 단위 쓰기 보호(Write Protection)를 사용자 공간 페이저가
관찰하고 제어할 수 있게 합니다. 겉보기에는 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 사용 가능 여부를 분리 측정
아키텍처별 차이 (x86-64 vs ARM64)
mprotect의 커널 코드(mm/mprotect.c)는 아키텍처에 독립적이지만,
실제 PTE 인코딩과 하드웨어 보호 메커니즘은 아키텍처마다 크게 다릅니다.
x86-64와 ARM64의 주요 차이를 비교합니다.
| 비교 항목 | x86-64 | ARM64 |
|---|---|---|
| 읽기/쓰기 분리 | R/W 단일 비트 (분리 불가) | AP[2:1]로 별도 제어 |
| Execute-only | 불가 (R 암묵적 포함) | 가능 (UXN=0, AP 읽기 차단) |
| 실행 금지 | NX 비트 (bit 63) | UXN + PXN (사용자/커널 분리) |
| 메모리 보호 키 | MPK (PKEY, 16개) | 없음 (Permission Overlay Extension 예정) |
| TLB 플러시 방식 | IPI 기반 소프트웨어 전파 | 하드웨어 브로드캐스트 (TLBI IS) |
| 메모리 태깅 | 없음 | MTE (4비트 태그, use-after-free 탐지) |
| PROT_NONE 인코딩 | _PAGE_PROTNONE (Global 비트 재활용) | PTE_PROT_NONE (소프트웨어 비트) |
| mprotect TLB 비용 | IPI → 원격 CPU 수에 비례 | 하드웨어 전파 → CPU 수 무관 |
mprotect(addr, len, PROT_EXEC)로 execute-only 매핑이 가능합니다.
이 모드에서는 코드를 실행할 수 있지만 읽을 수는 없어, ROP 가젯(Gadget) 수집을 어렵게 만듭니다.
x86-64에서는 불가능한 보호 모드입니다.
NUMA 밸런싱과 mprotect 재사용
Linux의 자동 NUMA 밸런싱(AutoNUMA)은 mprotect의 change_protection() 코드를 재사용하여
페이지를 PROT_NONE으로 만들고, 접근 fault를 통해 각 페이지의 최적 NUMA 노드를 결정합니다.
이 메커니즘은 mprotect 시스콜을 호출하지 않지만, 내부적으로 동일한 PTE 수정 경로를 사용합니다.
/* kernel/sched/fair.c - AutoNUMA 스캐너 */
static void task_numa_work(struct callback_head *work)
{
struct mm_struct *mm = current->mm;
struct vm_area_struct *vma;
unsigned long start, end;
/* 주기적으로 VMA를 스캔하며 PROT_NONE 설정 */
for_each_vma(vmi, vma) {
if (!vma_migratable(vma))
continue;
/* mprotect의 change_protection() 재사용! */
change_prot_numa(vma, start, end);
/*
* 내부적으로:
* change_protection(tlb, vma, start, end,
* PAGE_NONE, MM_CP_PROT_NUMA);
*
* → change_pte_range()에서 MM_CP_PROT_NUMA 감지
* → 이미 올바른 노드에 있는 페이지는 건너뜀
* → 나머지는 PROT_NONE으로 설정
*/
}
}
/* mm/memory.c - NUMA fault 핸들러 */
static vm_fault_t do_numa_page(struct vm_fault *vmf)
{
/* 현재 CPU의 NUMA 노드 확인 */
int cpu_nid = numa_node_id();
int page_nid = folio_nid(folio);
/* PTE 권한 즉시 복원 (접근 허용) */
ptent = pte_modify(ptent, vma->vm_page_prot);
/* 노드 불일치 시 마이그레이션 결정 */
if (page_nid != cpu_nid) {
migrate_misplaced_page(folio, vma, cpu_nid);
}
}
코드 설명
-
change_prot_numa()
mprotect의
change_protection()을MM_CP_PROT_NUMA플래그와 함께 호출합니다. 이 플래그가 있으면change_pte_range()에서 NUMA 최적화 로직이 활성화됩니다. - do_numa_page() PROT_NONE으로 설정된 페이지에 접근하면 page fault가 발생하고, 커널은 이를 NUMA fault로 인식합니다. 접근한 CPU의 NUMA 노드와 페이지 위치를 비교하여 마이그레이션을 결정합니다.
/proc/sys/kernel/numa_balancing으로 제어됩니다.
NUMA 밸런싱이 활성화되면 주기적으로 PTE가 PROT_NONE으로 변경되어 접근 패턴을 수집합니다.
이로 인해 일시적인 page fault 오버헤드가 발생하지만, 장기적으로 메모리 접근 지역성(Locality)이 향상되어
NUMA 시스템에서 전체 성능이 개선됩니다. 스캔 주기는 /proc/sys/kernel/numa_balancing_scan_period_min_ms와
numa_balancing_scan_period_max_ms로 조정합니다.
mprotect 성능 벤치마크 코드
mprotect의 성능 특성을 실측하기 위한 마이크로 벤치마크(Microbenchmark) 코드입니다. 페이지 수별 지연 시간, MPK 대비 성능, TLB 플러시 비용을 측정합니다.
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <sys/mman.h>
#include <time.h>
#include <string.h>
static inline uint64_t rdtsc(void)
{
uint32_t lo, hi;
__asm__ volatile("rdtsc" : "=a"(lo), "=d"(hi));
return ((uint64_t)hi << 32) | lo;
}
/* 페이지 수별 mprotect 지연 측정 */
void bench_mprotect_pages(int num_pages)
{
size_t size = num_pages * 4096;
void *mem = mmap(NULL, size, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS | MAP_POPULATE,
-1, 0);
if (mem == MAP_FAILED) return;
/* 페이지를 실제로 할당 (fault 제거) */
memset(mem, 0, size);
int iterations = 10000;
uint64_t start, end, total = 0;
for (int i = 0; i < iterations; i++) {
start = rdtsc();
mprotect(mem, size, PROT_READ);
end = rdtsc();
total += end - start;
mprotect(mem, size, PROT_READ | PROT_WRITE);
}
printf("Pages: %6d | Avg cycles: %8lu | ~%.2f us (@ 3GHz)\n",
num_pages, total / iterations,
(total / iterations) / 3000.0);
munmap(mem, size);
}
int main(void)
{
printf("=== mprotect 페이지 수별 성능 벤치마크 ===\n");
int sizes[] = {1, 4, 16, 64, 256, 1024, 4096, 16384};
for (int i = 0; i < sizeof(sizes)/sizeof(sizes[0]); i++)
bench_mprotect_pages(sizes[i]);
return 0;
}
코드 설명
- MAP_POPULATE 매핑 시 즉시 물리 페이지를 할당하여, 벤치마크에서 demand paging 오버헤드를 제거합니다.
- rdtsc x86의 TSC(Time Stamp Counter)를 읽어 사이클 단위로 정밀 측정합니다. 나노초 정밀도가 필요한 마이크로벤치마크에 적합합니다.
- 왕복 측정 RW→R로 변경 후 다시 R→RW로 복원합니다. 측정 대상은 첫 번째 mprotect만이며, 두 번째는 원래 상태 복원용입니다.
일반적인 측정 결과 (x86-64, 단일 스레드 기준):
| 페이지 수 | 영역 크기 | 평균 사이클 | 예상 지연 (3GHz) | 비고 |
|---|---|---|---|---|
| 1 | 4 KB | ~1,500 | ~0.5 μs | 시스콜 오버헤드 지배적 |
| 16 | 64 KB | ~3,000 | ~1.0 μs | PTE 순회 비용 증가 |
| 256 | 1 MB | ~15,000 | ~5.0 μs | TLB 플러시 비중 증가 |
| 1024 | 4 MB | ~60,000 | ~20 μs | PTE 순회 + TLB 플러시 |
| 16384 | 64 MB | ~900,000 | ~300 μs | 대규모 TLB shootdown |
| MPK (WRPKRU) | 무관 | ~20 | ~0.007 μs | 영역 크기 무관, 고정 비용 |
gcc -O2 -o mprotect_bench mprotect_bench.c
taskset -c 0 ./mprotect_bench # 단일 CPU 고정
perf stat ./mprotect_bench # 하드웨어 카운터 병행 수집
taskset으로 CPU를 고정하고, perf stat으로 IPI, TLB 플러시 카운터를 함께 수집하면 병목을 정확히 식별할 수 있습니다.
흔한 실수와 함정
mprotect를 사용할 때 자주 발생하는 실수와 함정을 정리합니다.
1. 주소 정렬 오류
/* ❌ 잘못된 사용: 정렬되지 않은 주소 */
char *buf = malloc(4096);
mprotect(buf, 4096, PROT_READ); /* EINVAL! malloc은 페이지 정렬 보장하지 않음 */
/* ✅ 올바른 사용: 페이지 정렬된 주소 */
void *buf = NULL;
posix_memalign(&buf, 4096, 4096); /* 페이지 정렬 보장 */
mprotect(buf, 4096, PROT_READ); /* OK */
/* ✅ 또는 mmap으로 직접 할당 (항상 페이지 정렬) */
void *buf = mmap(NULL, 4096, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
2. 인접 데이터 부수 효과
/* ❌ 위험: 같은 페이지의 다른 데이터에 영향 */
char *page = mmap(NULL, 4096, PROT_READ | PROT_WRITE, ...);
int *important_data = (int *)(page + 0); /* 오프셋 0 */
int *secret = (int *)(page + 2048); /* 같은 페이지! */
/* secret만 보호하려 했지만... */
mprotect(page, 4096, PROT_READ);
*important_data = 42; /* SIGSEGV! 같은 페이지이므로 함께 보호됨 */
/* ✅ 해결: 보호할 데이터를 별도 페이지에 배치 */
int *secret = mmap(NULL, 4096, PROT_READ | PROT_WRITE, ...);
int *data = mmap(NULL, 4096, PROT_READ | PROT_WRITE, ...);
3. VM_MAY* 제한 무시
/* ❌ 읽기 전용 파일 매핑에 쓰기 권한 추가 시도 */
int fd = open("readonly.txt", O_RDONLY);
void *p = mmap(NULL, 4096, PROT_READ, MAP_SHARED, fd, 0);
mprotect(p, 4096, PROT_READ | PROT_WRITE);
/* → EACCES: VM_MAYWRITE가 없으므로 실패 */
/* ✅ 해결: MAP_PRIVATE로 매핑하면 VM_MAYWRITE 설정됨 */
void *p = mmap(NULL, 4096, PROT_READ, MAP_PRIVATE, fd, 0);
mprotect(p, 4096, PROT_READ | PROT_WRITE); /* OK (COW 적용) */
4. VMA 단편화 폭발
/* ❌ 위험: 페이지마다 다른 보호 → VMA 폭발 */
void *region = mmap(NULL, 1024 * 4096, PROT_READ | PROT_WRITE, ...);
for (int i = 0; i < 1024; i += 2) {
/* 짝수 페이지만 PROT_READ → 512개의 VMA 분할! */
mprotect(region + i * 4096, 4096, PROT_READ);
}
/* → /proc/PID/maps에 1024개 VMA 생성 가능
* → vm.max_map_count (기본 65530) 초과 위험
* → find_vma() 성능 저하 */
/* ✅ 해결: 연속된 영역을 한 번에 변경하거나 MPK 사용 */
5. THP split 미인지
/* ❌ THP 영역의 부분 mprotect → huge page split */
void *p = mmap(NULL, 2 * 1024 * 1024, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
/* 커널이 THP로 2MB 매핑할 수 있음 */
mprotect(p + 4096, 4096, PROT_READ);
/* → 2MB THP가 512개의 4KB 페이지로 분할!
* → TLB miss 증가, 메모리 단편화 */
/* ✅ 해결: THP 경계에 맞춰 mprotect 호출 */
mprotect(p, 2 * 1024 * 1024, PROT_READ); /* 전체 2MB → split 불필요 */
6. SELinux execmem 차단
# JIT 컴파일러가 EACCES로 실패할 때
# audit 로그 확인
$ ausearch -m AVC -ts recent | grep mprotect
type=AVC msg=audit(...): avc: denied { execmem } for pid=1234 ...
# 임시 해결 (프로덕션에서는 정책 수정 권장)
$ setsebool -P allow_execmem 1
# 영구 해결: 해당 도메인에만 execmem 허용
# allow myapp_t self:process execmem;
| 실수 유형 | 증상 | 진단 방법 | 해결책 |
|---|---|---|---|
| 주소 미정렬 | EINVAL | strace로 인자 확인 | mmap 또는 posix_memalign 사용 |
| VM_MAY* 제한 | EACCES | /proc/PID/smaps의 VmFlags | 매핑 방식 변경 (MAP_PRIVATE 등) |
| VMA 폭발 | ENOMEM 또는 느려짐 | wc -l /proc/PID/maps | 연속 영역 통합 또는 MPK 사용 |
| THP split | TLB miss 증가 | perf stat의 dTLB-load-misses | THP 경계 정렬 또는 MADV_NOHUGEPAGE |
| SELinux 차단 | EACCES | ausearch -m AVC | 도메인별 execmem 정책 추가 |
| 시그널 경합 | SIGSEGV 루프 | strace -e signal | SA_ONSTACK + sigaltstack |
mprotect 관련 /proc 인터페이스
mprotect 동작의 결과와 영향을 관찰하기 위한 /proc 파일시스템 인터페이스를 정리합니다.
# 1. /proc/PID/maps - VMA별 보호 속성 확인
$ cat /proc/self/maps
# 주소범위 권한 오프셋 디바이스 inode 경로
# 7f8a12000000-7f8a12400000 r-xp 00000000 fd:01 12345 /usr/lib/libc.so.6
# ^^^^
# r = 읽기, - = 쓰기없음, x = 실행, p = private
# 2. /proc/PID/smaps - VMA 상세 정보 + VmFlags
$ grep -A 20 "7f8a12000000" /proc/self/smaps
# VmFlags: rd ex mr mw me sd
# rd = VM_READ mr = VM_MAYREAD
# -- = VM_WRITE 없음 mw = VM_MAYWRITE ← mprotect로 쓰기 추가 가능!
# ex = VM_EXEC me = VM_MAYEXEC
# sd = VM_SOFTDIRTY
# 3. /proc/PID/pagemap - 물리 주소 + soft-dirty 비트
# bit 55: soft-dirty (mprotect 후 clear_refs로 추적)
$ echo 4 > /proc/self/clear_refs # soft-dirty 비트 초기화
# → 이후 쓰기된 페이지만 soft-dirty=1
# 4. VMA 수 모니터링
$ wc -l /proc/self/maps # 현재 VMA 수
$ cat /proc/sys/vm/max_map_count # 최대 허용 VMA 수 (기본 65530)
# 5. NUMA 밸런싱 상태 확인
$ cat /proc/sys/kernel/numa_balancing # 0=비활성, 1=활성
$ cat /proc/vmstat | grep numa # NUMA 페이지 마이그레이션 통계
# numa_hit 12345 ← 로컬 노드 할당 성공
# numa_miss 678 ← 원격 노드에서 할당
# numa_pages_migrated 90 ← AutoNUMA 마이그레이션 횟수
# 6. MPK(PKRU) 상태 확인
$ cat /proc/self/arch_status
# PKRU: 0x55555554
# 각 2비트가 하나의 pkey: [AD WD] [AD WD] ...
# 0x55555554 = 0101...0100 → pkey 0만 접근 허용, 나머지 쓰기 금지
| /proc 파일 | 용도 | mprotect 관련 정보 |
|---|---|---|
/proc/PID/maps | VMA 목록 | 권한 문자열 (r/w/x/p/s) |
/proc/PID/smaps | VMA 상세 | VmFlags의 VM_MAY* 확인 |
/proc/PID/pagemap | PFN + 플래그 | soft-dirty 비트 (bit 55) |
/proc/PID/clear_refs | 참조 비트 초기화 | soft-dirty 추적 시작점 |
/proc/PID/arch_status | 아키텍처 상태 | PKRU 값 (MPK 상태) |
/proc/sys/vm/max_map_count | VMA 수 제한 | mprotect 분할로 인한 VMA 폭발 방지 |
/proc/vmstat | VM 통계 | numa_pages_migrated (AutoNUMA 활동) |
참고자료
- 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)
- mmap(2) — 메모리 보호 플래그 (PROT_READ, PROT_WRITE, PROT_EXEC)
- LWN: Memory sealing (2022) — 메모리 영역 보호 변경을 방지하는 봉인 메커니즘을 다룹니다
- arch/x86/mm/pkeys.c — x86 PKU(Protection Keys for Userspace) 구현 소스입니다
- Memory Protection Keys — Linux Kernel Documentation
- VMA / mmap -- 가상 메모리 매핑의 전체 구조
- MMU & TLB -- 페이지 테이블과 주소 변환(Address Translation) 메커니즘
- LSM / Seccomp -- 보안 모듈의 mprotect 제어
- 메모리 관리 개요 -- 고급 메모리 관리 주제