mprotect (메모리 보호 변경)

Linux 커널 mprotect 시스콜 심화: PROT_READ/WRITE/EXEC/NONE 보호 플래그, do_mprotect_pkey 커널 구현, VMA 분할/병합, 페이지 테이블 업데이트, Memory Protection Keys(MPK), W^X 정책, SELinux/AppArmor 제약, COW 상호작용, JIT 컴파일러/가드 페이지/샌드박스 활용 종합 가이드.

관련 페이지: 가상 메모리 매핑은 VMA / mmap 심화, 기본 메모리 관리는 메모리 관리 (기초), MMU/TLB는 MMU & TLB 페이지를 참고하세요. 보안 관련은 LSM/Seccomp 페이지를 참고하세요.
전제 조건: VMA / mmap 심화MMU & TLB 문서를 먼저 읽으세요. mprotect는 가상 메모리 영역(VMA)의 보호 속성을 변경하는 시스콜이므로, VMA 구조와 페이지 테이블의 기본 개념이 필요합니다.
일상 비유: mprotect는 건물 출입 권한 변경과 비슷합니다. 이미 할당된 사무실(메모리 영역)의 출입 카드 권한을 "읽기 전용", "읽기+쓰기", "출입 금지" 등으로 동적으로 변경하는 것과 같습니다. 권한 변경 시 해당 층의 모든 문(페이지 테이블 엔트리)을 업데이트해야 합니다.

핵심 요약

  • 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의 하드웨어 기반 메모리 보호 키로, 시스콜 없이 사용자 공간에서 권한을 전환할 수 있습니다.

단계별 이해

  1. 사용자 공간에서 mprotect() 호출
    프로세스가 mprotect(addr, len, prot)를 호출하면 커널로 트랩됩니다. addr은 페이지 정렬이어야 합니다.
  2. 커널이 VMA를 찾고 검증
    커널은 주소 범위에 해당하는 VMA를 찾아 보호 변경이 유효한지 검증합니다. LSM(SELinux 등)도 여기서 호출됩니다.
  3. VMA 분할 및 속성 변경
    변경 범위가 VMA 일부만 포함하면 VMA를 분할합니다. 그 후 vm_flags를 새 보호 플래그로 갱신합니다.
  4. 페이지 테이블 갱신 및 TLB 무효화
    대상 영역의 모든 PTE를 순회하며 권한 비트를 변경하고, TLB를 플러시하여 변경 사항을 즉시 반영합니다.

개요

mprotect()는 프로세스의 가상 주소 공간에서 이미 매핑된 메모리 영역의 접근 보호 속성을 변경하는 시스콜입니다. POSIX 표준에 정의되어 있으며, 메모리 보안의 핵심 메커니즘 중 하나입니다.

일반적인 사용 시나리오는 다음과 같습니다:

#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() 호출 syscall do_mprotect_pkey VMA 검색, 검증, LSM mprotect_fixup VMA 분할/병합 change_ protection split_vma VMA 분할 vma_merge 인접 VMA 병합 change_pte_range PTE 권한 비트 갱신 TLB Flush 캐시 무효화 보호 속성 변경 완료 새 vm_flags 반영, PTE 업데이트, TLB 플러시 완료 그림 1. mprotect() 시스콜 전체 처리 흐름

mprotect 시스콜 인터페이스

mprotect의 보호 플래그는 4가지 기본 값과 아키텍처별 확장으로 구성됩니다. 이 플래그들은 비트 OR로 조합하여 사용합니다.

플래그설명PTE 비트 (x86-64)
PROT_NONE0x0모든 접근 차단Present=0 (또는 특수 인코딩)
PROT_READ0x1읽기 허용Present=1, User=1
PROT_WRITE0x2쓰기 허용R/W=1
PROT_EXEC0x4실행 허용NX=0 (No Execute 해제)
PROT_GROWSDOWN0x01000000스택처럼 하향 확장 가능VMA 플래그 전용
PROT_GROWSUP0x02000000상향 확장 가능 (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 비트 없음)
 */
PROT 플래그 → vm_flags → PTE 변환 흐름 PROT_READ PROT_WRITE PROT_EXEC PROT_NONE calc_vm_prot_bits PROT → VM 변환 VM_READ VM_WRITE VM_EXEC VM_PKEY_BITn protection_map[16] vm_flags → pgprot (아키텍처별 테이블) PTE (Page Table Entry) Present | R/W | User | NX | PFN | PKEY (x86-64: bit 0~63 인코딩) 그림 2. PROT 플래그에서 PTE 권한 비트까지의 변환 경로

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 분할/병합과 페이지 테이블 업데이트를 수행하는 핵심 함수입니다.
do_mprotect_pkey() 상세 실행 흐름 1. 주소 정렬 및 범위 검증 start & ~PAGE_MASK == 0 ? 2. PROT → vm_flags 변환 calc_vm_prot_bits(prot, pkey) 3. VMA 검색 find_vma(mm, start) for (nstart = start; nstart < end; ...) 4. security_file_mprotect() LSM 보안 검사 5. mprotect_fixup() VMA 분할/병합 + PTE 변경 에러 반환 -EINVAL / -ENOMEM 통과 실패 6. 성공 (return 0) 모든 VMA 처리 완료 그림 3. do_mprotect_pkey() 단계별 실행 흐름도

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 분할(split) 과정 시각화 변경 전: VMA (vm_start=0x1000, vm_end=0x5000, RW) 0x1000 0x5000 요청: mprotect(0x2000, 0x2000, PROT_READ) 0x2000 0x4000 split_vma 분할 후: VMA1 (RW) 0x1000 0x2000 VMA2 (R) - 변경됨 0x2000 0x4000 VMA3 (RW) 0x4000 0x5000 병합 시: 인접 VMA의 보호 속성이 같으면 vma_merge()로 하나로 합칩니다. VMA_A (RW) VMA_B (RW) merge VMA_AB (RW) 그림 4. mprotect에 의한 VMA 분할 및 병합 과정

페이지 테이블 업데이트

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 플러시 대상에 추가합니다.
페이지 테이블 계층 순회 (change_protection) PGD Level 4 P4D Level 3 PUD Level 2 PMD Level 1 PTE (Level 0) change_pte_range PTE 변경 전: P=1 R/W=1 U/S=1 NX=0 PKEY=0 PFN = 0x1A3F00 (물리 프레임 번호) pte_modify(oldpte, newprot) PTE 변경 후: P=1 R/W=0 U/S=1 NX=1 PKEY=0 PFN = 0x1A3F00 (변경 없음) TLB Flush (IPI 전파) tlb_finish_mmu() → flush_tlb_range() PROT_READ | PROT_WRITE | PROT_EXEC → PROT_READ 변경 시: R/W=1→0 (쓰기 차단), NX=0→1 (실행 차단), PFN 유지 그림 5. 페이지 테이블 엔트리(PTE) 권한 비트 갱신 과정

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사이클 미만의 비용으로 매우 빠릅니다.
MPK (Memory Protection Keys) 동작 구조 CPU 레지스터 PKRU (32비트) Key0 Key1 Key2 ... 각 키: WD(쓰기금지) + AD(접근금지) = 2비트 참조 MMU 검사 1. PTE.Present? 2. PTE.R/W + U/S? 3. PTE.PKEY → PKRU? 모두 통과해야 접근 허용 접근 허용 #PF (폴트) PTE (x86-64) P R/W U/S NX PKEY[59:62] PFN (물리 주소) PKEY 값으로 PKRU 슬롯 참조 mprotect() - 전통적 방식 시스콜 오버헤드 + VMA 수정 + PTE 갱신 + TLB 플러시 수천~수만 사이클 WRPKRU - MPK 방식 사용자 공간 명령어 1개 (시스콜 불필요) 약 20 사이클 그림 6. MPK 하드웨어 구조와 전통적 mprotect 대비 성능 차이
MPK 제한사항: 보호 키는 최대 16개(4비트)입니다. pkey 0은 기본값으로 예약되어 실제 사용 가능한 키는 15개입니다. 또한 커널 공간에서는 PKRU를 무시하므로, 커널 코드가 사용자 메모리에 접근할 때는 MPK 보호가 적용되지 않습니다.

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;
}
W^X (Write XOR Execute) 정책 다이어그램 쓰기(W) 실행(X) 허용 여부 사용 사례 OFF OFF 허용 읽기 전용 데이터 ON OFF 허용 일반 데이터/힙/스택 OFF ON 허용 코드(.text) 영역 ON ON 거부 (W^X) 코드 주입 위험! JIT 컴파일러의 안전한 W^X 패턴: 1. RW 할당 2. 코드 생성(W) 3. mprotect(RX) 4. 실행(X) 그림 7. W^X 정책 매트릭스와 JIT 안전 패턴

SELinux/AppArmor와 mprotect 제약

LSM(Linux Security Module) 프레임워크는 mprotect 호출 시 security_file_mprotect() 훅을 통해 보안 정책을 강제합니다. SELinux와 AppArmor는 각각 다른 방식으로 mprotect를 제한합니다.

LSM정책제한 내용우회 조건
SELinuxexecmem익명 메모리에 PROT_EXEC 추가 차단allow process execmem
SELinuxexecmod쓰기된 파일 매핑에 PROT_EXEC 추가 차단allow process execmod
SELinuxexecheap힙 영역에 PROT_EXEC 추가 차단allow process execheap
AppArmormxmmap 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 컴파일러가 이 권한을 필요로 합니다.
보안 주의: SELinux의 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_KEYSy (x86-64)MPK 하드웨어 지원 활성화
CONFIG_ARCH_HAS_PKEYS자동아키텍처별 pkey 지원
CONFIG_STRICT_KERNEL_RWXy커널 코드/데이터 W^X 강제
CONFIG_STRICT_MODULE_RWXy모듈 코드/데이터 W^X 강제
CONFIG_DEFAULT_MMAP_MIN_ADDR65536NULL 포인터 역참조 방지 최소 주소
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의 성능은 대상 영역의 크기와 매핑된 페이지 수에 비례합니다. 주요 비용 요소를 이해하고 최적화하는 것이 중요합니다.

mprotect 성능 비용 분석 0 20% 40% 60% 80% 100% 시간 비율 syscall 50% 1 페이지 ~0.5us PTE 순회 30% 100 페이지 ~5us PTE 순회 50% 10K 페이지 ~500us WRPKRU MPK ~0.02us PTE 순회/syscall VMA 처리 TLB 플러시 MPK (WRPKRU) 그림 8. 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),
};
에러 처리: mprotect가 실패하는 주요 원인:
  • 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 Shootdown 흐름 CPU0 (호출 CPU) sys_mprotect() mm/mprotect.c change_protection_range() flush_tlb_range() tlb_gather_mmu 연계 CPU1 동일 mm 실행 중 CPU2 동일 mm 실행 중 CPU3 동일 mm 실행 중 IPI 핸들러 로컬 TLB invalidate 모든 대상 CPU가 TLB 무효화 완료 후 반환 CPU 수가 많고 mm 공유가 넓을수록 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 IPIIPI 인터럽트와 TLB flush 카운트 증가
대형 주소 범위PTE 순회 + flush 범위 확대sys_enter_mprotect 대비 커널 체류 시간 급증

THP 및 HugeTLB 매핑에서의 mprotect

4KB 페이지에서는 PTE 단위로 권한이 바뀌지만, THP(예: PMD 크기 2MB)나 HugeTLB(예: 2MB/1GB)에서는 처리 경로가 달라집니다. 일부 경우 mprotect가 huge mapping을 분할(split)해 일반 페이지로 내려오며, 이 과정에서 성능과 메모리 단편화에 영향을 줄 수 있습니다.

THP 영역 부분 mprotect 시 split 가능성 기존 PMD-mapped THP (2MB) 하나의 huge entry 부분 범위 mprotect 요청 예: 가운데 64KB만 RX split_huge_pmd() PTE 512개로 분해 분할 이후: 4KB 단위 PTE 권한 관리 일부 구간만 권한 변경 가능하지만 TLB/페이지테이블 비용 증가
중요: JIT처럼 잦은 권한 전환이 필요한 워크로드에서 THP가 활성화되어 있으면 mprotect로 인해 huge page split이 반복될 수 있습니다. 성능 민감 경로에서는 MADV_NOHUGEPAGE 또는 명시적 매핑 정책을 검토하세요.
매핑 유형mprotect 처리 특성실무 영향
일반 4KB 페이지PTE 단위 권한 변경세밀 제어 용이, 페이지 수 많으면 순회 비용 증가
THP (PMD)부분 변경 시 split 가능일시적/지속적 단편화, TLB 이점 감소
HugeTLBhugetlb 전용 경로, 제약 엄격권한 변경 정책이 더 제한적일 수 있음

userfaultfd Write-Protect와 mprotect 차이

userfaultfd의 write-protect(UFFD-WP)는 페이지 단위 쓰기 보호를 사용자 공간 페이저가 관찰하고 제어할 수 있게 합니다. 겉보기에는 mprotect(PROT_READ)와 유사하지만, 목적과 fault 처리 모델이 다릅니다.

mprotect vs userfaultfd-wp 동작 비교 mprotect 경로 1) mprotect(PROT_READ) 호출 2) 커널이 PTE 권한 즉시 갱신 3) 이후 쓰기 접근은 SIGSEGV 가능 4) 정책 적용 중심 (권한 통제) 5) fault 중재 로직은 프로세스 외부에 없음 userfaultfd-wp 경로 1) UFFDIO_WRITEPROTECT 설정 2) 쓰기 fault 발생 시 이벤트 전달 3) 사용자 공간 페이저가 처리/동기화 4) UFFDIO_CONTINUE/WAKE로 재개 5) 라이브 마이그레이션/스냅샷에 적합 선택 기준 권한 정책 자체가 목적이면 mprotect, fault 중재/복제 제어면 userfaultfd-wp
/* 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, &reg);

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 카운터를 함께 보면 병목을 빠르게 좁힐 수 있습니다.

mprotect 문제 분석 파이프라인 현상 관찰 지연/SIGSEGV/EACCES syscall 계측 perf trace, strace 커널 함수 추적 ftrace/kprobe 원인 확정 개선 적용 핵심 관측 지표 - sys_enter_mprotect / sys_exit_mprotect 지연 분포 - do_mprotect_pkey, change_protection_range 체류 시간 - tlb_flush 관련 이벤트, IPI 인터럽트, 스케줄링 지연
# 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 사용 가능 여부를 분리 측정

참고자료

다음 학습: