시스템 콜 (System Call)
사용자 공간과 커널 공간을 잇는 핵심 인터페이스인 시스템 콜을 아키텍처별 진입 경로부터 반환까지 추적합니다. x86_64 SYSCALL/SYSRET, ARM64 SVC, entry_SYSCALL_64 디스패치, SYSCALL_DEFINE 매크로 전개, 사용자 메모리 접근 검증, vDSO 경량 경로, seccomp/ptrace/audit 정책 훅, compat ABI 및 restart 경로까지 실무 중심으로 상세히 다룹니다.
핵심 요약
- 시스템 콜 — 사용자 프로그램이 커널 서비스를 요청하는 유일한 공식 인터페이스입니다.
- SYSCALL/SYSRET — x86_64에서 사용자↔커널 전환을 수행하는 CPU 명령어입니다.
- sys_call_table — 시스템 콜 번호를 실제 커널 함수에 매핑하는 함수 포인터 테이블입니다.
- vDSO — 커널 진입 없이 사용자 공간에서 실행되는 가상 동적 공유 객체 (gettimeofday 등).
- seccomp-BPF — 프로세스가 사용할 수 있는 시스템 콜을 BPF 필터로 제한하는 보안 기능입니다.
- ARM64 SVC #0 — ARM64 아키텍처에서
SVC #0명령어와x8레지스터로 시스템 콜을 호출합니다 (x86_64의SYSCALL/rax에 해당). - KPTI — Meltdown(CVE-2017-5754) 완화를 위해 사용자/커널 페이지 테이블을 분리하는 기법으로, 1~30%의 성능 오버헤드가 발생합니다.
- copy_to_user / access_ok — 커널에서 사용자 메모리에 안전하게 접근하기 위한 필수 함수로, SMAP 하드웨어 보호와 협력합니다.
단계별 이해
- 호출 흐름 — 사용자 프로그램 → glibc 래퍼 →
SYSCALL명령어 → 커널 진입 → 핸들러 실행 → 결과 반환.이 전체 과정이 수백 나노초 안에 완료됩니다.
- strace로 관찰 —
strace ls를 실행하면ls가 호출하는 모든 시스템 콜을 볼 수 있습니다.open(),read(),write()등 실제 시스템 콜과 그 인자/반환값을 확인합니다. - 커널 내부 —
SYSCALL_DEFINE3(read, ...)매크로가sys_read()함수를 정의합니다.시스템 콜 번호는
arch/x86/entry/syscalls/syscall_64.tbl에 정의되어 있습니다. - 보안 — seccomp-BPF로 컨테이너나 샌드박스에서 허용할 시스템 콜을 화이트리스트로 제한합니다.
Docker, Chrome 등이 seccomp을 사용하여 공격 표면을 줄입니다.
- 성능 측정 —
perf stat -e syscalls:sys_enter_read로 시스템 콜 횟수를 측정하고,bpftrace로 레이턴시 분포를 확인합니다.vDSO는 커널 진입 없이 실행되어
clock_gettime을 3~10배 빠르게 만듭니다. io_uring은 배치 처리로 syscall 오버헤드를 줄입니다. - 보안 취약점 분석 — Meltdown(CVE-2017-5754)은 시스템 콜 경계를 통해 커널 메모리를 읽었고, KPTI 패치가 이를 해결했습니다.
Spectre v2 완화(RETPOLINE)는 시스템 콜 디스패치 경로의 간접 점프를 보호합니다.
array_index_nospec()는 Spectre v1을 방어합니다.
시스템 콜 개요
시스템 콜(system call)은 사용자 공간 프로그램이 커널의 서비스를 요청하기 위한 프로그래밍 인터페이스입니다. 프로세스 생성, 파일 읽기/쓰기, 네트워크 통신, 메모리 할당 등 하드웨어 자원에 접근하는 모든 작업은 시스템 콜을 통해야 합니다. Linux 커널은 약 450개 이상의 시스템 콜을 제공합니다.
보호 링과 특권 수준
x86 아키텍처는 4개의 보호 링(Ring 0~3)을 제공하지만, Linux는 Ring 0(커널 모드)과 Ring 3(사용자 모드) 두 가지만 사용합니다. 시스템 콜은 사용자 모드에서 커널 모드로의 제어된 전환을 수행하는 유일한 합법적 경로입니다.
| 특권 수준 | Ring | CPL | 접근 가능 영역 |
|---|---|---|---|
| 커널 모드 | Ring 0 | 0 | 모든 메모리, I/O 포트, 특권 명령어 |
| 사용자 모드 | Ring 3 | 3 | 사용자 공간 메모리만 (가상 주소 하위 영역) |
int 0x80에서 SYSCALL까지의 변천
x86 시스템 콜 호출 메커니즘은 성능 향상을 위해 발전해 왔습니다.
| 메커니즘 | 아키텍처 | 도입 시기 | 특징 |
|---|---|---|---|
int 0x80 |
i386 | 초기 | 소프트웨어 인터럽트, IDT 조회 필요 → 느림 |
sysenter/sysexit |
i386 (Pentium II+) | 2000년대 | Intel 전용 빠른 진입, MSR 기반 |
SYSCALL/SYSRET |
x86_64 | AMD64 | 64비트 표준, MSR_LSTAR 기반, 가장 빠름 |
SVC #0 |
ARM64 (AArch64) | ARMv8+ | Supervisor Call, x8 레지스터로 번호 전달, EL0→EL1 전환 |
ecall |
RISC-V | RISC-V ISA | Environment Call, a7 레지스터로 번호 전달 |
int 0x80은 IDT(Interrupt Descriptor Table)를 조회하고 스택 전환을 수행하므로 수백 사이클이 소요됩니다. SYSCALL은 IDT를 우회하고 MSR에 미리 저장된 진입점으로 직접 점프하므로 훨씬 빠릅니다.
/* int 0x80 (레거시 32비트) - 느린 경로 */
mov $__NR_write, %eax /* 시스템 콜 번호 */
mov $1, %ebx /* fd = stdout */
mov $msg, %ecx /* buffer */
mov $len, %edx /* count */
int $0x80 /* 소프트웨어 인터럽트 */
/* SYSCALL (x86_64) - 빠른 경로 */
mov $1, %rax /* __NR_write */
mov $1, %rdi /* fd = stdout */
mov $msg, %rsi /* buffer */
mov $len, %rdx /* count */
syscall /* 빠른 시스템 콜 진입 */
시스템 콜 아키텍처
x86_64 레지스터 규약
x86_64에서 SYSCALL 명령어를 사용할 때의 레지스터 규약은 다음과 같습니다. 이는 C 함수 호출 규약(System V AMD64 ABI)과 유사하지만 rcx 대신 r10을 사용하는 점이 다릅니다.
| 레지스터 | 용도 | 비고 |
|---|---|---|
rax |
시스템 콜 번호 / 반환값 | 호출 전: 번호, 반환 후: 결과 또는 -errno |
rdi |
1번째 인자 | |
rsi |
2번째 인자 | |
rdx |
3번째 인자 | |
r10 |
4번째 인자 | C ABI에서는 rcx이지만, SYSCALL이 rcx를 덮어쓰므로 r10 사용 |
r8 |
5번째 인자 | |
r9 |
6번째 인자 | |
rcx |
복귀 주소 (RIP) | SYSCALL이 자동으로 RIP → RCX 저장 |
r11 |
RFLAGS 백업 | SYSCALL이 자동으로 RFLAGS → R11 저장 |
SYSCALL 명령어는 하드웨어적으로 rcx에 복귀 주소(RIP)를, r11에 RFLAGS를 저장합니다. 따라서 이 두 레지스터의 원래 값은 시스템 콜 수행 후 복원되지 않습니다. glibc의 syscall wrapper는 이를 고려하여 r10으로 4번째 인자를 전달합니다.
시스템 콜 흐름 다이어그램
ARM64 시스템 콜 구현
ARM64(AArch64) 아키텍처는 x86_64와 다른 시스템 콜 메커니즘을 사용합니다. SVC #0(Supervisor Call) 명령어로 커널에 진입하며, 시스템 콜 번호는 x8 레지스터에 전달합니다. x86_64에서 rax가 번호와 반환값을 겸용하는 것과 달리, ARM64는 번호에 x8, 반환값에 x0을 사용합니다. 어셈블리 종합 페이지에서 ARM64 호출 규약 전반을 확인할 수 있습니다.
ARM64 vs x86_64 레지스터 규약 비교
| 용도 | x86_64 레지스터 | ARM64 레지스터 |
|---|---|---|
| 시스템 콜 번호 | rax |
x8 |
| 반환값 | rax |
x0 |
| 1~6번째 인자 | rdi, rsi, rdx, r10, r8, r9 |
x0, x1, x2, x3, x4, x5 |
| 호출 명령어 | SYSCALL |
SVC #0 |
| 모드 전환 | Ring 3 → Ring 0 (CPL 변경) | EL0 → EL1 (Exception Level 변경) |
| 진입점 결정 | MSR_LSTAR 레지스터 |
VBAR_EL1 예외 벡터 테이블 |
| 커널 진입 함수 | entry_SYSCALL_64 |
el0t_64_sync_handler |
| C 디스패처 | do_syscall_64() |
do_el0_svc() |
| 반환 명령어 | SYSRET |
ERET |
/* ARM64 시스템 콜 예제 — write(1, buf, 13) */
mov x8, #64 /* __NR_write = 64 (ARM64 UAPI 번호) */
mov x0, #1 /* 1번째 인자: fd = stdout */
ldr x1, =buf /* 2번째 인자: buffer 주소 */
mov x2, #13 /* 3번째 인자: 바이트 수 */
svc #0 /* Supervisor Call → el0t_64_sync_handler */
/* 반환값은 x0에 저장됨 (bytes written 또는 -errno) */
/* x86_64 비교 — write(1, buf, 13) */
mov $1, %rax /* __NR_write = 1 (x86_64) */
mov $1, %rdi /* 1번째 인자: fd */
mov $buf, %rsi /* 2번째 인자: buffer */
mov $13, %rdx /* 3번째 인자: count */
syscall /* → entry_SYSCALL_64 */
/* 반환값은 rax에 저장됨 */
el0_svc 진입점 분석
ARM64에서 SVC #0 명령어는 EL0(사용자)에서 EL1(커널)로 동기 예외(Synchronous Exception)를 발생시킵니다. 커널은 VBAR_EL1 레지스터가 가리키는 예외 벡터 테이블에서 핸들러를 찾아 실행합니다.
/* arch/arm64/kernel/entry.S (간략화) */
SYM_CODE_START_LOCAL(el0t_64_sync_handler)
/* ESR_EL1: Exception Syndrome Register에서 예외 종류 판단 */
ldr x24, [sp, #S_ESR]
ubfx x24, x24, #ESR_ELx_EC_SHIFT, #ESR_ELx_EC_WIDTH
cmp x24, #ESR_ELx_EC_SVC64 /* SVC 64비트인지 확인 */
b.eq el0_svc
el0_svc:
mov x0, sp /* 1번째 인자: pt_regs 포인터 */
bl do_el0_svc
/* arch/arm64/kernel/syscall.c */
void do_el0_svc(struct pt_regs *regs)
{
invoke_syscall(regs,
regs->regs[8], /* x8 = 시스템 콜 번호 */
__NR_syscalls,
sys_call_table);
}
static void invoke_syscall(struct pt_regs *regs,
unsigned int scno,
unsigned int sc_nr,
const syscall_fn_t syscall_table[])
{
long ret;
scno = array_index_nospec(scno, sc_nr); /* Spectre v1 방어 */
ret = syscall_table[scno](regs);
regs->regs[0] = ret; /* 반환값 → x0 */
}
write는 x86_64에서 1번이지만 ARM64에서는 64번입니다. ARM64는 AArch64/AArch32 통합을 위해 번호 체계를 새로 설계했습니다. arch/arm64/tools/syscall.tbl에서 확인할 수 있습니다.
AArch32 호환성 계층
ARM64 커널은 CONFIG_COMPAT 옵션으로 32비트 ARM(AArch32) 프로그램을 지원합니다. AArch32에서의 시스템 콜은 swi #0(Software Interrupt) 명령어를 사용하며, 번호는 r7에 전달합니다. ARM64 커널의 compat 진입점은 el0_svc_compat이며, 별도의 compat_sys_call_table을 사용합니다. x86_64의 ia32_sys_call_table과 동일한 역할입니다.
ARM64 vs x86_64 시스템 콜 경로 비교 다이어그램
시스템 콜 테이블
sys_call_table은 시스템 콜 번호를 해당 핸들러 함수 포인터에 매핑하는 배열입니다. x86_64에서는 arch/x86/entry/syscall_64.c에 정의됩니다.
/* arch/x86/entry/syscall_64.c */
#include <asm/syscalls_64.h>
#define __SYSCALL(nr, sym) [nr] = __x64_##sym,
const sys_call_ptr_t sys_call_table[] __ro_after_init = {
[0 ... __NR_syscall_max] = __x64_sys_ni_syscall,
#include <asm/syscalls_64.h>
};
이 테이블은 __ro_after_init으로 선언되어 부팅 후에는 읽기 전용이 됩니다. 이는 루트킷이 시스템 콜 테이블을 변조하는 것을 방지하는 보안 장치입니다.
시스템 콜 번호 할당
시스템 콜 번호는 arch/x86/entry/syscalls/syscall_64.tbl 테이블 파일에서 관리됩니다. 빌드 시 이 파일로부터 헤더가 자동 생성됩니다.
# arch/x86/entry/syscalls/syscall_64.tbl
# <번호> <ABI> <이름> <진입점>
0 common read sys_read
1 common write sys_write
2 common open sys_open
3 common close sys_close
...
56 common clone sys_clone
57 common fork sys_fork
59 common execve sys_execve
...
435 common clone3 sys_clone3
주요 시스템 콜 번호를 확인하려면:
# 사용자 공간에서 시스템 콜 번호 헤더 확인
$ grep -E '__NR_(read|write|open|close|fork|clone|execve) ' \
/usr/include/asm/unistd_64.h
#define __NR_read 0
#define __NR_write 1
#define __NR_open 2
#define __NR_close 3
sys_ni_syscall(not implemented)로 매핑되어 -ENOSYS를 반환합니다.
SYSCALL_DEFINE 매크로
DEFINE0~6 패밀리
커널 시스템 콜 핸들러는 SYSCALL_DEFINEN 매크로로 정의됩니다. N은 인자 개수(0~6)를 나타냅니다. 이 매크로는 함수 프로토타입 생성, 인자 타입 검증, 추적(tracing) 지원, CVE 방지용 타입 캐스팅을 자동으로 처리합니다.
/* include/linux/syscalls.h */
#define SYSCALL_DEFINE0(name) ...
#define SYSCALL_DEFINE1(name, type1, arg1) ...
#define SYSCALL_DEFINE2(name, type1, arg1, type2, arg2) ...
/* ... SYSCALL_DEFINE6까지 */
/* 내부적으로 확장되는 구조 */
#define SYSCALL_DEFINE3(name, ...) \
SYSCALL_METADATA(name, 3, __VA_ARGS__) \
__SYSCALL_DEFINEx(3, _##name, __VA_ARGS__)
SYSCALL_METADATA는 ftrace의 syscall tracepoint에 사용되며, __SYSCALL_DEFINEx는 실제 함수 본체를 정의합니다. 인자를 long으로 받아 올바른 타입으로 캐스팅하여 레지스터 상위 비트 오염 공격(CVE-2009-0029)을 방지합니다.
실제 커널 코드 예제
/* fs/read_write.c - read 시스템 콜 */
SYSCALL_DEFINE3(read,
unsigned int, fd,
char __user *, buf,
size_t, count)
{
return ksys_read(fd, buf, count);
}
/* kernel/fork.c - getpid (인자 없음) */
SYSCALL_DEFINE0(getpid)
{
return task_tgid_vnr(current);
}
/* mm/mmap.c - mmap (인자 6개, 최대) */
SYSCALL_DEFINE6(mmap,
unsigned long, addr,
unsigned long, len,
unsigned long, prot,
unsigned long, flags,
unsigned long, fd,
unsigned long, off)
{
return ksys_mmap_pgoff(addr, len, prot, flags, fd,
off >> PAGE_SHIFT);
}
시스템 콜 진입 경로 상세
entry_SYSCALL_64 분석
entry_SYSCALL_64는 x86_64에서 모든 64비트 시스템 콜의 커널 진입점입니다. MSR_LSTAR에 이 함수의 주소가 저장되어 있어, SYSCALL 명령어 실행 시 하드웨어가 이 주소로 점프합니다. 어셈블리 종합 페이지와 MSR 레지스터 페이지에서 관련 세부 사항을 확인할 수 있습니다.
/* arch/x86/entry/entry_64.S (간략화) */
SYM_CODE_START(entry_SYSCALL_64)
/* SYSCALL 직후: RCX=사용자RIP, R11=사용자RFLAGS
* RSP는 아직 사용자 스택! */
swapgs /* GS base를 커널용으로 전환 */
/* 사용자 RSP를 per-CPU 영역에 임시 저장 */
movq %rsp, PER_CPU_VAR(cpu_tss_rw + TSS_sp2)
/* 커널 스택으로 전환 (per-CPU에서 로드) */
movq PER_CPU_VAR(pcpu_hot + X86_top_of_stack), %rsp
/* pt_regs 구조체 형태로 레지스터 저장 */
pushq $__USER_DS /* pt_regs->ss */
pushq PER_CPU_VAR(cpu_tss_rw + TSS_sp2) /* pt_regs->sp */
pushq %r11 /* pt_regs->flags */
pushq $__USER_CS /* pt_regs->cs */
pushq %rcx /* pt_regs->ip */
pushq %rax /* pt_regs->orig_ax (syscall nr) */
/* 나머지 범용 레지스터 저장 */
PUSH_AND_CLEAR_REGS rax=$-ENOSYS
/* C 핸들러 호출 */
movq %rsp, %rdi /* 1번째 인자: pt_regs 포인터 */
movslq %eax, %rsi /* 2번째 인자: syscall 번호 */
call do_syscall_64
/* 반환 경로: 레지스터 복원 후 SYSRET */
...
swapgs
sysretq
SYM_CODE_END(entry_SYSCALL_64)
swapgs는 사용자 GS base와 커널 GS base를 교환합니다. 이 명령어가 빠지면 커널이 사용자 제어 GS base를 사용하게 되어 보안 취약점이 발생합니다. Spectre v1 변종 공격에서 swapgs 경계가 표적이 된 사례(CVE-2019-1125)가 있습니다.
pt_regs 구조체
struct pt_regs는 시스템 콜(또는 인터럽트/예외) 진입 시 저장되는 사용자 레지스터 상태를 담는 구조체입니다. 커널 스택에 이 구조체 형태로 레지스터를 push합니다.
/* arch/x86/include/asm/ptrace.h */
struct pt_regs {
unsigned long r15, r14, r13, r12;
unsigned long bp, bx;
unsigned long r11, r10, r9, r8;
unsigned long ax, cx, dx, si, di;
unsigned long orig_ax; /* 시스템 콜 번호 */
unsigned long ip; /* 사용자 RIP (RCX에서) */
unsigned long cs;
unsigned long flags; /* 사용자 RFLAGS (R11에서) */
unsigned long sp; /* 사용자 RSP */
unsigned long ss;
};
do_syscall_64()
do_syscall_64()는 어셈블리 진입점에서 호출되는 C 함수로, 시스템 콜 번호 검증과 디스패치를 수행합니다.
/* arch/x86/entry/common.c */
__visible noinstr void do_syscall_64(
struct pt_regs *regs, int nr)
{
add_random_kstack_offset();
nr = syscall_enter_from_user_mode(regs, nr);
instrumentation_begin();
if (!do_syscall_x64(regs, nr))
do_syscall_x32(regs, nr); /* x32 ABI 폴백 */
instrumentation_end();
syscall_exit_to_user_mode(regs);
}
static __always_inline bool do_syscall_x64(
struct pt_regs *regs, int nr)
{
if (likely(nr < NR_syscalls)) {
nr = array_index_nospec(nr, NR_syscalls);
regs->ax = sys_call_table[nr](regs);
return true;
}
return false;
}
array_index_nospec()는 Spectre v1 방어를 위한 투기적 실행 차단 함수입니다.
진입 경로 상세 다이어그램
사용자 메모리 접근 보안
커널 코드에서 시스템 콜 인자로 전달받은 사용자 포인터에 접근할 때는 반드시 전용 함수를 사용해야 합니다. 잘못된 포인터, 커널 공간 주소 위장, TOCTOU 공격 등을 방지하기 위한 다층 보안 메커니즘이 적용됩니다.
access_ok() — 주소 범위 검증
access_ok(addr, size)는 사용자 포인터가 사용자 공간 범위 내에 있는지 검증합니다. 커널 주소를 사용자인 척 전달하는 공격을 차단하는 첫 번째 방어선입니다.
/* include/linux/uaccess.h */
/* access_ok()는 아키텍처마다 구현이 다르지만 목적은 동일:
* 주소가 사용자 공간 범위(< TASK_SIZE_MAX)인지 확인 */
/* x86_64 구현 */
static inline bool access_ok(const void __user *addr, unsigned long size)
{
return likely((unsigned long)addr + size <= TASK_SIZE_MAX);
}
/* __user 어노테이션: Sparse 정적 분석 도구가 포인터 혼용 검출 */
SYSCALL_DEFINE3(read,
unsigned int, fd,
char __user *, buf, /* 사용자 공간 포인터 표시 */
size_t, count)
{
/* copy_to_user 내부에서 access_ok 자동 호출 */
return ksys_read(fd, buf, count);
}
copy_to_user / copy_from_user
가장 일반적인 사용자 메모리 접근 함수입니다. 내부적으로 access_ok()를 호출하고, SMAP/PAN 하드웨어 보호를 우회하는 특수 어셈블리를 포함합니다.
/* include/linux/uaccess.h */
/* 커널 → 사용자 복사 (sys_read 등의 반환 경로) */
static inline unsigned long
copy_to_user(void __user *to, const void *from, unsigned long n)
{
if (!access_ok(to, n))
return n; /* 복사 실패: 미복사 바이트 수 반환 */
return raw_copy_to_user(to, from, n);
}
/* 사용자 → 커널 복사 (sys_write 등의 입력 경로) */
static inline unsigned long
copy_from_user(void *to, const void __user *from, unsigned long n)
{
if (!access_ok(from, n))
return n;
return raw_copy_from_user(to, from, n);
}
/* 소량 데이터용 매크로: sizeof(val)을 자동으로 처리 */
int val;
get_user(val, user_ptr); /* 사용자 → 커널, 에러 시 -EFAULT */
put_user(val, user_ptr); /* 커널 → 사용자, 에러 시 -EFAULT */
/* 실제 커널 코드 예시: sys_hello 핸들러 */
SYSCALL_DEFINE1(hello, char __user *, buf)
{
const char msg[] = "Hello from kernel!\n";
if (copy_to_user(buf, msg, sizeof(msg)))
return -EFAULT; /* 복사 실패 시 EFAULT 반환 */
return sizeof(msg);
}
copy_to_user()와 copy_from_user()는 복사에 실패한 바이트 수를 반환합니다 (성공 시 0). 0이 아닌 값이 반환되면 반드시 -EFAULT를 반환해야 합니다. 이를 무시하면 데이터 손상이나 정보 누출이 발생할 수 있습니다.
SMAP과 SMEP — 하드웨어 보호
현대 CPU는 커널 코드가 사용자 공간 메모리에 직접 접근하거나 사용자 코드를 실행하는 것을 하드웨어 수준에서 차단합니다.
| 기능 | 전체 이름 | 보호 내용 | 활성화 |
|---|---|---|---|
| SMEP | Supervisor Mode Execution Prevention | 커널 모드에서 사용자 공간 코드 실행 방지 | CR4.SMEP 비트 |
| SMAP | Supervisor Mode Access Prevention | 커널 모드에서 사용자 공간 메모리 읽기/쓰기 차단 | CR4.SMAP 비트 |
| PAN | Privileged Access Never (ARM64) | ARM64의 SMAP 상당 기능 | PSTATE.PAN 비트 |
| PXN | Privileged Execute Never (ARM64) | ARM64의 SMEP 상당 기능 | 페이지 테이블 PXN 비트 |
/* SMAP이 활성화된 경우 커널이 사용자 메모리에 접근하려면
* STAC(Set AC) 명령어로 일시 비활성화 후 CLAC(Clear AC)로 복원 */
/* arch/x86/include/asm/uaccess.h */
static inline void user_access_begin(const void __user *ptr, size_t len)
{
access_ok(ptr, len);
__uaccess_begin_nospec(); /* STAC + LFENCE (Spectre v1 방어) */
}
static inline void user_access_end(void)
{
__uaccess_end(); /* CLAC 명령어 실행 */
}
/* raw_copy_to_user 내부에서 사용 */
user_access_begin(to, n);
/* ... rep movsb 등 실제 복사 어셈블리 ... */
user_access_end();
/* SMAP 활성화 여부 확인 */
$ grep SMAP /proc/cpuinfo | head -1
flags : ... smap smep ...
__user 어노테이션과 Sparse 정적 분석
__user는 GCC/Clang에서는 무시되지만, Sparse 정적 분석 도구가 사용자 포인터와 커널 포인터의 혼용을 검출하는 데 사용합니다. 커널 빌드 시 make C=1로 Sparse를 활성화할 수 있습니다.
# Sparse로 __user 어노테이션 위반 검사
$ make C=1 drivers/char/mem.o
drivers/char/mem.c:300:26: warning: incorrect type in argument 1
expected void [noderef] __user *to, got char *
# 커널 전체 소스 검사 (시간이 오래 걸림)
$ make C=2 2>&1 | grep "address space"
SMAP/SMEP 보안 레이어 다이어그램
새로운 시스템 콜 추가
단계별 가이드
리눅스 커널에 새로운 시스템 콜을 추가하는 과정을 단계별로 설명합니다. 아래는 sys_hello라는 예시 시스템 콜을 추가하는 과정입니다.
1단계: 시스템 콜 테이블에 번호 등록
# arch/x86/entry/syscalls/syscall_64.tbl 끝에 추가
# 번호는 마지막 항목 + 1
451 common hello sys_hello
2단계: 시스템 콜 핸들러 구현
/* kernel/sys_hello.c */
#include <linux/syscalls.h>
#include <linux/uaccess.h>
SYSCALL_DEFINE1(hello, char __user *, buf)
{
const char msg[] = "Hello from kernel!\\n";
if (copy_to_user(buf, msg, sizeof(msg)))
return -EFAULT;
return sizeof(msg);
}
3단계: 헤더에 프로토타입 선언
/* include/linux/syscalls.h 에 추가 */
asmlinkage long sys_hello(char __user *buf);
4단계: Makefile에 오브젝트 추가
# kernel/Makefile
obj-y += sys_hello.o
5단계: 사용자 공간에서 호출
/* userspace test program */
#include <unistd.h>
#include <sys/syscall.h>
#include <stdio.h>
#define __NR_hello 451
int main(void)
{
char buf[64];
long ret = syscall(__NR_hello, buf);
if (ret > 0)
printf("%s", buf);
return 0;
}
clone → clone3, epoll_create → epoll_create1).
호환성 계층
compat_sys_* 래퍼
64비트 커널에서 32비트 사용자 프로그램을 실행할 때, 시스템 콜 인자의 크기가 다릅니다 (예: long이 4바이트 vs 8바이트). compat_sys_* 래퍼는 32비트 인자를 64비트로 안전하게 변환합니다.
/* fs/read_write.c */
COMPAT_SYSCALL_DEFINE3(read,
unsigned int, fd,
char __user *, buf,
compat_size_t, count)
{
return ksys_read(fd, buf, (size_t)count);
}
/* 타입 변환이 필요한 구조체 예시 */
struct compat_stat {
compat_dev_t st_dev; /* 32비트 dev_t */
compat_ino_t st_ino; /* 32비트 ino_t */
compat_off_t st_size; /* 32비트 off_t */
/* ... */
};
32비트 호환 모드 (IA-32 Emulation)
x86_64 Linux 커널은 CONFIG_IA32_EMULATION 옵션으로 32비트 프로그램의 int 0x80 및 sysenter 호출을 지원합니다. 32비트 시스템 콜은 별도의 테이블(ia32_sys_call_table)을 사용하며, 번호 체계도 다릅니다.
/* arch/x86/entry/entry_64_compat.S */
SYM_CODE_START(entry_SYSENTER_compat)
/* 32비트 sysenter 진입점 */
swapgs
/* 32비트 레지스터에서 인자 추출 */
movl %ebp, %r10d /* ebp → r10 (4번째 인자) */
...
call do_SYSENTER_32
SYM_CODE_END(entry_SYSENTER_compat)
| 항목 | 64비트 (native) | 32비트 (compat) |
|---|---|---|
| 호출 명령어 | SYSCALL |
int 0x80 / sysenter |
| 시스템 콜 테이블 | sys_call_table |
ia32_sys_call_table |
| 번호 헤더 | unistd_64.h |
unistd_32.h |
| write 번호 | 1 | 4 |
| 인자 전달 | rdi, rsi, rdx, r10, r8, r9 | ebx, ecx, edx, esi, edi, ebp |
vDSO (virtual Dynamic Shared Object)
개념과 목적
vDSO는 커널이 사용자 공간 프로세스의 주소 공간에 매핑하는 작은 공유 라이브러리입니다. 커널 모드 전환 없이 특정 시스템 콜을 사용자 공간에서 직접 실행할 수 있게 합니다. 이를 통해 gettimeofday(), clock_gettime(), getcpu() 등 자주 호출되는 시스템 콜의 오버헤드를 제거합니다.
# vDSO 매핑 확인
$ cat /proc/self/maps | grep vdso
7ffd3c5fe000-7ffd3c600000 r-xp 00000000 00:00 0 [vdso]
# vDSO에서 제공하는 함수 목록
$ objdump -T /proc/self/root/$(readlink /proc/self/exe) 2>/dev/null || \
LD_SHOW_AUXV=1 /bin/true | grep VDSO
$ vdso=$(dd if=/proc/self/mem bs=1 skip=$((0x7ffd3c5fe000)) count=8192 2>/dev/null | file -)
vDSO가 가속하는 함수들:
__vdso_clock_gettime()- 나노초 정밀도 시간 조회__vdso_gettimeofday()- 마이크로초 정밀도 시간 조회__vdso_clock_getres()- 시계 해상도 조회__vdso_getcpu()- 현재 CPU/NUMA 노드 조회__vdso_time()- 초 단위 시간 조회
vvar 페이지(읽기 전용)를 사용자 공간에 매핑합니다. 타이머 인터럽트마다 커널이 이 페이지를 갱신하고, vDSO 함수는 이 데이터를 직접 읽어 반환합니다. 자세한 내용은 ktime / Clock 심화 페이지를 참조하세요.
구현
/* arch/x86/entry/vdso/vclock_gettime.c (간략화) */
notrace int __vdso_clock_gettime(clockid_t clock,
struct timespec *ts)
{
/* 커널 진입 없이 vvar 페이지에서 시간 데이터 읽기 */
const struct vdso_data *vd = __arch_get_vdso_data();
if (vd->clock_mode != VDSO_CLOCKMODE_NONE) {
/* TSC 기반 고속 경로 */
u64 cycles = __arch_get_hw_counter(vd->clock_mode, vd);
ns = (cycles - vd->cycle_last) * vd->mult;
ns >>= vd->shift;
ts->tv_sec = vd->basetime[clock].sec + ns / NSEC_PER_SEC;
ts->tv_nsec = ns % NSEC_PER_SEC;
return 0;
}
/* TSC 불안정 시 실제 시스템 콜로 폴백 */
return clock_gettime_fallback(clock, ts);
}
vDSO vs vsyscall
| 항목 | vsyscall (레거시) | vDSO (현재) |
|---|---|---|
| 주소 | 고정 (0xFFFFFFFFFF600000) | ASLR 적용 (매 실행마다 변경) |
| 크기 | 1 페이지 | 2~4 페이지 |
| 보안 | ROP gadget 표적 (고정 주소) | ASLR로 보호 |
| 에뮬레이션 모드 | vsyscall=emulate (기본) |
해당 없음 |
| 함수 수 | 3개 (time, gettimeofday, getcpu) | 5개+ |
| 상태 | 레거시 (호환용만 유지) | 표준 |
vsyscall=none으로 완전히 비활성화할 수도 있습니다.
Seccomp (Secure Computing Mode)
seccomp-bpf 필터링
Seccomp은 프로세스가 호출할 수 있는 시스템 콜을 제한하는 리눅스 커널 보안 기능입니다. 원래 모드(strict)는 read, write, exit, sigreturn 4개만 허용했으나, seccomp-bpf(filter 모드)에서는 BPF 프로그램으로 세밀한 필터링이 가능합니다.
/* seccomp-bpf 필터 예제: write만 허용하는 필터 */
#include <linux/seccomp.h>
#include <linux/filter.h>
#include <sys/prctl.h>
struct sock_filter filter[] = {
/* 아키텍처 검증 */
BPF_STMT(BPF_LD | BPF_W | BPF_ABS,
offsetof(struct seccomp_data, arch)),
BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, AUDIT_ARCH_X86_64,
1, 0),
BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_KILL_PROCESS),
/* 시스템 콜 번호 로드 */
BPF_STMT(BPF_LD | BPF_W | BPF_ABS,
offsetof(struct seccomp_data, nr)),
/* write(1) 허용 */
BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_write, 0, 1),
BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW),
/* exit_group(231) 허용 */
BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_exit_group, 0, 1),
BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW),
/* 나머지 모두 거부 */
BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_KILL_PROCESS),
};
struct sock_fprog prog = {
.len = (unsigned short)(sizeof(filter) / sizeof(filter[0])),
.filter = filter,
};
/* 필터 설치 */
prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0);
prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &prog);
seccomp 필터의 반환값(action):
| 반환값 | 동작 |
|---|---|
SECCOMP_RET_ALLOW |
시스템 콜 허용 |
SECCOMP_RET_KILL_PROCESS |
프로세스 종료 (SIGSYS) |
SECCOMP_RET_KILL_THREAD |
해당 스레드만 종료 |
SECCOMP_RET_TRAP |
SIGSYS 시그널 전송 (핸들링 가능) |
SECCOMP_RET_ERRNO |
지정한 errno 반환 |
SECCOMP_RET_TRACE |
ptrace tracer에 통지 |
SECCOMP_RET_LOG |
허용하되 로그 기록 |
SECCOMP_RET_USER_NOTIF |
사용자 공간 알림 fd로 전달 |
컨테이너 보안과 seccomp
컨테이너 런타임(Docker, containerd 등)은 seccomp 프로파일을 사용하여 컨테이너 내부에서 위험한 시스템 콜을 차단합니다. Docker의 기본 프로파일은 약 44개의 시스템 콜을 차단합니다. 자세한 컨테이너 보안 설정은 Linux Containers 심화 페이지를 참조하세요.
# Docker 기본 seccomp 프로파일에서 차단되는 주요 시스템 콜
# (보안 위험이 높은 시스템 콜)
$ docker run --rm alpine cat /proc/1/status | grep Seccomp
Seccomp: 2 # 2 = SECCOMP_MODE_FILTER
# 차단되는 예시:
# - mount, umount2 : 파일시스템 조작
# - reboot : 호스트 재부팅
# - kexec_load : 커널 교체
# - init_module : 커널 모듈 로드
# - bpf : BPF 프로그램 로드
# - userfaultfd : 사용자 페이지 폴트 핸들링 (exploit primitive)
# 커스텀 seccomp 프로파일로 컨테이너 실행
$ docker run --security-opt seccomp=my-profile.json alpine sh
fork()/clone()으로 생성된 자식 프로세스는 부모의 필터를 상속하며, 추가 필터를 설치하면 기존 필터에 AND 조건으로 결합됩니다. 따라서 필터는 점점 더 제한적으로만 변경 가능합니다.
KPTI와 보안 완화
2018년 공개된 Meltdown과 Spectre 취약점은 시스템 콜 메커니즘의 보안 가정에 근본적인 의문을 제기했습니다. 이를 완화하기 위한 기법들이 커널에 통합되었으며, 시스템 콜 성능에 상당한 영향을 미쳤습니다. 커널 보안과 커널 취약점 분석 페이지도 참고하세요.
Meltdown (CVE-2017-5754)과 KPTI
Meltdown은 CPU의 투기적 실행(Speculative Execution)을 이용하여 사용자 프로세스가 시스템 콜 수행 중 커널 메모리를 사이드채널로 읽을 수 있는 취약점입니다. CPU가 권한 검사 결과를 기다리지 않고 투기적으로 커널 데이터를 캐시에 올리기 때문에 발생합니다.
KPTI (Kernel Page-Table Isolation)는 사용자 모드와 커널 모드에서 서로 다른 CR3 레지스터값(페이지 테이블)을 사용하여 Meltdown을 차단합니다. 사용자 모드 페이지 테이블에는 커널 진입에 필요한 최소한의 코드(entry_SYSCALL_64)만 매핑됩니다.
# KPTI 활성화 여부 확인
$ cat /sys/devices/system/cpu/vulnerabilities/meltdown
Mitigation: PTI
# CPU 취약점 전체 상태 확인
$ grep . /sys/devices/system/cpu/vulnerabilities/*
/sys/devices/system/cpu/vulnerabilities/meltdown:Mitigation: PTI
/sys/devices/system/cpu/vulnerabilities/spectre_v1:Mitigation: usercopy/swapgs barriers and __user pointer sanitization
/sys/devices/system/cpu/vulnerabilities/spectre_v2:Mitigation: Retpolines; IBPB: conditional; IBRS_FW; RSB filling
# KPTI 비활성화 부팅 옵션 (가상 환경에서 성능 우선 시)
# GRUB_CMDLINE_LINUX="nopti"
# 커널 설정에서 KPTI 및 Spectre 완화 항목 확인
$ grep -E 'CONFIG_RETPOLINE|CONFIG_PAGE_TABLE_ISOLATION' /boot/config-$(uname -r)
CONFIG_RETPOLINE=y
CONFIG_PAGE_TABLE_ISOLATION=y
Spectre v1 — 경계 검사 우회 (CVE-2017-5753)
CPU가 분기 예측 실패 시에도 투기적으로 실행하는 코드에서 비밀 데이터를 캐시 사이드채널로 유출하는 취약점입니다. do_syscall_x64()의 배열 인덱스 검증이 우회 대상이므로, array_index_nospec()으로 완화합니다.
/* Spectre v1 취약점 패턴 */
if (user_nr < NR_syscalls) {
/* CPU가 투기적으로 실행: 범위 밖 sys_call_table[user_nr] 접근 가능 */
regs->ax = sys_call_table[user_nr](regs); /* 취약 */
}
/* 완화: array_index_nospec() — 투기적 실행 시 안전한 인덱스 보장 */
if (user_nr < NR_syscalls) {
user_nr = array_index_nospec(user_nr, NR_syscalls);
regs->ax = sys_call_table[user_nr](regs); /* 안전 */
}
/* arch/x86/entry/common.c 실제 코드 */
static __always_inline bool do_syscall_x64(struct pt_regs *regs, int nr)
{
if (likely(nr < NR_syscalls)) {
nr = array_index_nospec(nr, NR_syscalls);
regs->ax = sys_call_table[nr](regs);
return true;
}
return false;
}
Spectre v2 — 간접 분기 예측 주입 (CVE-2017-5715)
공격자가 CPU의 간접 분기 예측기(BTB: Branch Target Buffer)를 조작하여 피해자 프로세스의 코드에서 원하는 가젯을 투기적으로 실행시키는 취약점입니다. sys_call_table[nr](regs)와 같은 간접 함수 호출이 표적이 됩니다.
| 완화 기법 | 방식 | 성능 영향 |
|---|---|---|
| RETPOLINE | 간접 점프를 ret 명령어로 변환하여 BTB 예측 우회 |
낮음 (컴파일러 변환) |
| IBRS | Indirect Branch Restricted Speculation: 커널 모드에서 BTB 격리 | 높음 (매 syscall마다) |
| IBPB | Indirect Branch Predictor Barrier: 컨텍스트 전환 시 BTB 플러시 | 중간 (컨텍스트 전환마다) |
| eIBRS | Enhanced IBRS: 하드웨어 지원 IBRS (Ice Lake+) | 매우 낮음 |
swapgs 취약점 (CVE-2019-1125)
swapgs는 entry_SYSCALL_64의 첫 번째 명령어입니다. Spectre v1 변종이 이 경계를 이용하여 swapgs 실행 전에 투기적으로 커널 GS 기반에 접근하는 취약점이 발견되었습니다. 완화책으로 swapgs 전후에 LFENCE 명령어가 삽입되었습니다.
KPTI 성능 영향
| 워크로드 | KPTI 오버헤드 | 비고 |
|---|---|---|
| 데이터베이스 (PostgreSQL) | 5~17% | syscall 빈도 높음 |
| 웹 서버 (Nginx) | 3~10% | accept/send 빈도 높음 |
| 컴파일 (gcc) | 1~3% | 파일 I/O 위주 |
| 인메모리 캐시 (Redis) | 20~30% | 초고속 syscall 루프 |
| 가상 머신 내부 | 거의 없음 | PCID + 하이퍼바이저 최적화 |
CONFIG_X86_64 기본 활성화). ARM64에서는 ASID(Address Space ID)가 동일한 역할을 합니다.
시스템 콜 추적과 디버깅
strace
strace는 프로세스의 시스템 콜 호출을 추적하는 사용자 공간 도구입니다. 내부적으로 ptrace() 시스템 콜을 사용하여 대상 프로세스의 syscall 진입/반환을 가로챕니다.
# 기본 추적: 시스템 콜과 반환값 출력
$ strace ls /tmp
execve("/usr/bin/ls", ["ls", "/tmp"], ...) = 0
openat(AT_FDCWD, "/tmp", O_RDONLY|O_DIRECTORY) = 3
getdents64(3, ..., 32768) = 240
write(1, "file1.txt\\nfile2.log\\n", 20) = 20
close(3) = 0
exit_group(0) = ?
# 시간 측정: 각 시스템 콜의 소요 시간
$ strace -T -e trace=read,write cat /dev/null
read(3, "", 131072) = 0 <0.000008>
# 통계 요약: 시스템 콜별 호출 횟수와 시간
$ strace -c ls /tmp
% time calls syscall
------ -------- --------
25.00 10 mmap
18.75 5 openat
12.50 5 close
6.25 3 read
6.25 1 write
# 특정 시스템 콜만 추적
$ strace -e trace=network nginx # 네트워크 관련만
$ strace -e trace=%file ls # 파일 관련만
$ strace -e trace=%process bash # 프로세스 관련만
ftrace 시스템 콜 tracepoint
ftrace의 시스템 콜 tracepoint는 ptrace보다 훨씬 가벼운 커널 내부 추적 메커니즘입니다. SYSCALL_METADATA 매크로가 생성한 tracepoint를 활용합니다.
# 시스템 콜 tracepoint 활성화
$ cd /sys/kernel/tracing
# 사용 가능한 시스템 콜 이벤트 확인
$ ls events/syscalls/ | head
sys_enter_read
sys_exit_read
sys_enter_write
sys_exit_write
# read 시스템 콜 진입/반환 추적
$ echo 1 > events/syscalls/sys_enter_read/enable
$ echo 1 > events/syscalls/sys_exit_read/enable
$ cat trace
<...>-1234 [002] .... 12345.678: sys_read(fd: 3, buf: 7ffd..., count: 4096)
<...>-1234 [002] .... 12345.679: sys_read -> 0x100
# 필터 적용: 특정 PID의 시스템 콜만
$ echo 'common_pid == 1234' > events/syscalls/sys_enter_write/filter
audit 서브시스템
Linux audit 프레임워크는 보안 감사를 위해 시스템 콜을 기록합니다. 시스템 콜 진입 경로의 syscall_enter_from_user_mode()에서 audit 검사가 수행됩니다.
# 파일 삭제 관련 시스템 콜 감사 규칙 추가
$ auditctl -a always,exit -F arch=b64 -S unlink,unlinkat,rename,renameat \
-F auid>=1000 -k file_delete
# 감사 로그 검색
$ ausearch -k file_delete -ts recent
type=SYSCALL msg=audit(1707000000.123:456): arch=c000003e syscall=263 \
success=yes exit=0 a0=ffffff9c a1=7ffd... auid=1000 uid=1000 \
comm="rm" exe="/usr/bin/rm"
# 실행 파일 실행 감사
$ auditctl -a always,exit -F arch=b64 -S execve -k exec_log
동적 추적 심화
시스템 콜 추적에는 여러 도구가 있으며 각자 오버헤드와 기능이 다릅니다. strace는 사용이 간편하지만 ptrace 기반으로 오버헤드가 크고, perf trace와 bpftrace는 훨씬 가벼운 커널 tracepoint를 사용합니다. BPF/XDP와 ftrace 페이지도 참고하세요.
perf trace — 고성능 시스템 콜 추적
perf trace는 strace와 유사한 출력을 제공하지만, ptrace 대신 커널 tracepoint를 사용하여 10~100배 낮은 오버헤드로 동작합니다. 프로덕션 환경에서도 사용 가능합니다.
# perf trace: strace와 유사하지만 낮은 오버헤드
$ sudo perf trace ls /tmp
0.000 ( 0.020 ms): ls/1234 execve("/usr/bin/ls", ...) = 0
0.234 ( 0.005 ms): ls/1234 brk(NULL) = 0x...
2.100 ( 0.008 ms): ls/1234 openat(AT_FDCWD, "/tmp", O_RDONLY) = 3
2.200 ( 0.003 ms): ls/1234 getdents64(3, ..., 32768) = 240
# 특정 시스템 콜만 추적 (실행 중인 프로세스)
$ sudo perf trace -e 'syscalls:sys_enter_read,syscalls:sys_exit_read' \
-p $(pgrep nginx)
# 전체 시스템의 시스템 콜 통계 집계 (5초)
$ sudo perf trace -s --duration 5000 2>/dev/null
syscall calls total min avg max
--------------- -------- --------- --------- --------- ---------
futex 12542 1523.232 0.001 121.440 9999.000
epoll_wait 3201 4521.100 0.002 1412.400 10000.000
read 8901 45.231 0.001 5.082 102.400
# 시스템 콜별 시간 측정 (perf stat)
$ perf stat -e 'syscalls:sys_enter_read,syscalls:sys_enter_write' \
dd if=/dev/zero of=/dev/null bs=4096 count=100000
100,000 syscalls:sys_enter_read
100,000 syscalls:sys_enter_write
bpftrace — eBPF 기반 추적
bpftrace는 커널 tracepoint와 kprobes에 eBPF 프로그램을 부착하여 고성능 추적과 통계 수집을 수행합니다. one-liner 문법으로 강력한 분석이 가능합니다.
# read() 레이턴시 분포 히스토그램
$ sudo bpftrace -e '
tracepoint:syscalls:sys_enter_read { @ts[tid] = nsecs; }
tracepoint:syscalls:sys_exit_read {
@usecs = hist((nsecs - @ts[tid]) / 1000);
delete(@ts[tid]);
}
END { print(@usecs); }'
# 프로세스별 시스템 콜 횟수 집계 (top 20)
$ sudo bpftrace -e '
tracepoint:raw_syscalls:sys_enter {
@[comm, args->id] = count();
}
END { print(@, 20); }'
# 특정 UID의 execve 추적
$ sudo bpftrace -e '
tracepoint:syscalls:sys_enter_execve
/uid == 1000/ {
printf("PID %d (%s) exec: %s\n", pid, comm, str(args->filename));
}'
# write() fd별 바이트 수 집계
$ sudo bpftrace -e '
tracepoint:syscalls:sys_enter_write {
@bytes[args->fd] = sum(args->count);
}'
# 느린 시스템 콜 식별 (100μs 이상)
$ sudo bpftrace -e '
tracepoint:raw_syscalls:sys_enter { @start[tid] = nsecs; }
tracepoint:raw_syscalls:sys_exit {
$lat = (nsecs - @start[tid]) / 1000;
if ($lat > 100) {
printf("slow syscall: pid=%d comm=%s lat=%dμs\n",
pid, comm, $lat);
}
delete(@start[tid]);
}'
kprobes로 시스템 콜 핸들러 내부 계측
kprobes는 커널 함수 심볼에 동적으로 브레이크포인트를 삽입하여 시스템 콜 핸들러 내부를 계측할 수 있습니다. 커널 디버깅 페이지에서 kprobes 전반을 다룹니다.
/* kprobe를 이용한 시스템 콜 핸들러 내부 계측 */
#include <linux/kprobes.h>
static int handler_pre(struct kprobe *p, struct pt_regs *regs)
{
/* ksys_read() 진입 시 호출: fd와 count 출력 */
pr_info("ksys_read: fd=%ld, count=%ld\n",
regs->di, regs->dx);
return 0;
}
static struct kprobe kp = {
.symbol_name = "ksys_read",
.pre_handler = handler_pre,
};
static int __init probe_init(void)
{
return register_kprobe(&kp);
}
/* bpftrace one-liner로 동일한 효과 */
$ sudo bpftrace -e '
kprobe:ksys_read {
printf("pid=%d fd=%d count=%d\n", pid, arg0, arg2);
}'
strace는 ptrace(PTRACE_SYSCALL)로 매 시스템 콜마다 대상 프로세스를 중단시켜 100배 이상의 오버헤드가 발생할 수 있습니다. perf trace는 ring buffer 기반 tracepoint를 사용하여 오버헤드가 1~5% 이하입니다. bpftrace는 eBPF JIT 컴파일로 더 낮은 오버헤드를 달성합니다. 프로덕션 환경에서는 항상 perf/bpftrace를 사용하세요.
시스템 콜 성능 분석
시스템 콜은 모드 전환, 레지스터 저장, 커널 스택 전환 등의 비용으로 수백 나노초가 소요됩니다. KPTI와 Spectre 완화까지 고려하면 오버헤드는 더 커집니다. vDSO와 io_uring을 활용하면 이 오버헤드를 크게 줄일 수 있습니다.
시스템 콜 레이턴시 수치
| 방식 | 레이턴시 (ns) | 조건 |
|---|---|---|
vDSO clock_gettime |
5~15 ns | 커널 진입 없음, TSC 직접 읽기 |
getpid() — KPTI 없음 |
80~120 ns | x86_64 Skylake, PCID 있음 |
getpid() — KPTI 있음 |
150~250 ns | PCID 없는 경우 TLB flush 추가 |
read() — 파이프 캐시 히트 |
300~600 ns | 1바이트, 컨텍스트 스위치 없음 |
| io_uring — 배치 처리 | 50~100 ns/I/O | SQ/CQ 링버퍼, 여러 I/O 묶음 처리 |
vDSO 성능 이점
/* vDSO vs syscall 성능 비교 측정 */
#include <time.h>
#include <stdio.h>
#include <unistd.h>
#include <sys/syscall.h>
#define ITER 10000000
int main(void)
{
struct timespec ts, t1, t2;
/* glibc clock_gettime: vDSO를 통해 커널 진입 없이 실행 */
clock_gettime(CLOCK_MONOTONIC, &t1);
for (int i = 0; i < ITER; i++)
clock_gettime(CLOCK_MONOTONIC, &ts);
clock_gettime(CLOCK_MONOTONIC, &t2);
printf("vDSO clock_gettime: %.1f ns/call\n",
(t2.tv_nsec - t1.tv_nsec + (t2.tv_sec - t1.tv_sec) * 1e9) / ITER);
/* syscall()로 강제 커널 진입 — vDSO 우회 */
clock_gettime(CLOCK_MONOTONIC, &t1);
for (int i = 0; i < ITER; i++)
syscall(__NR_clock_gettime, CLOCK_MONOTONIC, &ts);
clock_gettime(CLOCK_MONOTONIC, &t2);
printf("syscall clock_gettime: %.1f ns/call\n",
(t2.tv_nsec - t1.tv_nsec + (t2.tv_sec - t1.tv_sec) * 1e9) / ITER);
/* 전형적인 결과: vDSO ~10ns vs syscall ~200ns (10~20x 차이) */
return 0;
}
io_uring vs 전통적 I/O
io_uring은 공유 링버퍼(Submission Queue / Completion Queue)로 I/O 요청과 완료를 전달하여 다수의 I/O를 단 한 번의 시스템 콜로 처리합니다. SQPOLL 모드에서는 커널 스레드가 폴링하여 시스템 콜 자체를 제거합니다.
| 방식 | I/O당 syscall 수 | 상대적 IOPS |
|---|---|---|
전통적 read()/write() |
1 syscall / I/O | 기준선 (1x) |
preadv2()/pwritev2() |
1 syscall / 여러 I/O | ~1.2x |
| io_uring (기본 모드) | 1 syscall / 배치 | ~1.5x |
| io_uring (SQPOLL 모드) | 0 syscall (커널 폴링) | ~2x (CPU 1코어 점유) |
시스템 콜 방식별 성능 비교
주요 시스템 콜 분류
Linux 시스템 콜은 기능에 따라 다음과 같이 분류됩니다. 관련 문서 페이지로의 교차 참조를 포함합니다.
| 카테고리 | 대표 시스템 콜 | 관련 페이지 |
|---|---|---|
| 프로세스 | fork, clone, clone3, execve, exit, wait4, getpid |
프로세스 관리 |
| 파일 I/O | open, read, write, close, lseek, pread64, pwrite64 |
VFS, inode |
| 메모리 | mmap, munmap, mprotect, brk, madvise, mlock |
메모리 관리 |
| 네트워크 | socket, bind, listen, accept, connect, sendto, recvfrom |
네트워크 스택 |
| IPC | pipe, shmget, semget, msgget, eventfd, signalfd |
IPC 메커니즘 |
| 시그널 | kill, rt_sigaction, rt_sigprocmask, sigaltstack |
시그널 처리 |
| 파일시스템 관리 | statfs, mount, umount2, pivot_root, chroot |
VFS, 네임스페이스 |
| 시간 | clock_gettime, nanosleep, timer_create, timerfd_create |
ktime / Clock, 타이머 |
| 네임스페이스 | unshare, setns, clone (CLONE_NEW*) |
네임스페이스 |
| 보안 | prctl, seccomp, setuid, setgid, capset |
Linux Containers |
| 비동기 I/O | io_uring_setup, io_uring_enter, epoll_create1, select, poll |
성능 최적화 |
| 모듈/드라이버 | init_module, finit_module, delete_module, ioctl |
커널 모듈, 디바이스 드라이버 |
| BPF | bpf, perf_event_open |
BPF/XDP |
man 2 syscalls 명령어로 현재 시스템에서 지원하는 전체 시스템 콜 목록을 확인할 수 있습니다. 커널 소스의 include/linux/syscalls.h에 모든 시스템 콜의 프로토타입이 선언되어 있습니다.
x86_64 시스템 콜 진입 전체 경로
x86_64 아키텍처에서 사용자 프로그램이 시스템 콜을 호출할 때의 전체 경로를 하드웨어 수준부터 C 핸들러 반환까지 상세히 추적합니다. SYSCALL 명령어와 SYSENTER 명령어의 차이, entry_SYSCALL_64의 내부 동작, 그리고 SYSRET과 IRET 반환 경로의 선택 기준을 분석합니다.
SYSCALL vs SYSENTER
SYSCALL(AMD 제안, x86_64 표준)과 SYSENTER(Intel 제안, 32비트 전용)는 모두 빠른 시스템 콜 진입을 위한 CPU 명령어이지만, 동작 방식과 사용 맥락이 다릅니다. 64비트 모드에서는 SYSCALL만 사용됩니다.
| 항목 | SYSCALL/SYSRET | SYSENTER/SYSEXIT |
|---|---|---|
| 출처 | AMD (K6-2, 1998) | Intel (Pentium II, 1997) |
| 64비트 지원 | x86_64 표준 (필수) | 32비트 전용 (Long Mode 미지원) |
| 진입점 MSR | MSR_LSTAR (0xC0000082) |
MSR_IA32_SYSENTER_EIP (0x176) |
| RIP 저장 | 하드웨어가 RCX ← RIP |
소프트웨어가 스택에 push |
| RFLAGS 저장 | 하드웨어가 R11 ← RFLAGS |
저장하지 않음 |
| 스택 전환 | 소프트웨어 (entry_SYSCALL_64) | 하드웨어 (MSR_IA32_SYSENTER_ESP) |
| Linux 사용 | 64비트 모드 전용 | 32비트 compat 모드 (entry_SYSENTER_compat) |
MSR 초기화: 진입점 등록
커널 부팅 시 syscall_init()이 호출되어 MSR_LSTAR에 entry_SYSCALL_64의 주소를 기록합니다. 이후 모든 SYSCALL 명령어는 이 주소로 점프합니다.
/* arch/x86/kernel/cpu/common.c */
void syscall_init(void)
{
wrmsr(MSR_STAR, 0, (__USER32_CS << 16) | __KERNEL_CS);
wrmsrl(MSR_LSTAR, (unsigned long)entry_SYSCALL_64);
wrmsrl(MSR_CSTAR, (unsigned long)ignore_sysret);
/* SYSCALL 시 마스크할 RFLAGS 비트: IF, TF, DF, AC, NT */
wrmsrl(MSR_SYSCALL_MASK,
X86_EFLAGS_TF | X86_EFLAGS_DF | X86_EFLAGS_IF |
X86_EFLAGS_AC | X86_EFLAGS_NT);
}
SYSCALL 명령어 실행 시 하드웨어가 이 마스크에 지정된 RFLAGS 비트를 자동으로 클리어합니다. IF(인터럽트 플래그)를 클리어하면 커널 진입 초기에 인터럽트가 비활성화되어 안전하게 스택 전환을 수행할 수 있습니다. AC 비트 클리어는 SMAP 보호를 활성 상태로 유지합니다.
entry_SYSCALL_64 전체 실행 시퀀스
다음 다이어그램은 SYSCALL 명령어 실행부터 SYSRET/IRET 반환까지의 전체 경로를 보여줍니다. 각 단계에서 수행되는 하드웨어/소프트웨어 동작을 구분합니다.
SYSRET vs IRET 반환 경로 선택
SYSRET은 SYSCALL의 역연산으로 가장 빠른 반환 경로이지만, 모든 상황에서 사용할 수 없습니다. 특정 조건에서는 더 느리지만 안전한 IRET 경로를 선택해야 합니다.
| 조건 | 반환 경로 | 사유 |
|---|---|---|
| 정상적인 64비트 시스템 콜 | SYSRET |
가장 빠른 경로 |
| 사용자 RIP가 비표준(non-canonical) 주소 | IRET |
SYSRET이 GP fault를 Ring 0에서 발생시키는 버그 방지 (CVE-2014-4699) |
| ptrace가 RIP/RFLAGS를 변경한 경우 | IRET |
임의의 RFLAGS 복원 필요 |
| 시그널 전달 중 | IRET |
수정된 pt_regs로 복귀 |
| NMI/인터럽트 중첩 | IRET |
중첩된 스택 프레임 안전 복원 |
/* arch/x86/entry/entry_64.S — SYSRET 가능 여부 검사 */
SYM_INNER_LABEL(entry_SYSCALL_64_after_hwframe)
...
/* RCX(복귀 RIP)가 canonical 주소인지 확인 */
movq RCX(%rsp), %rcx
movq RIP(%rsp), %r11
cmpq %rcx, %r11
jne swapgs_restore_regs_and_return_to_usermode /* → IRET */
/* RFLAGS 검사: IOPL이 0이 아니면 IRET 사용 */
testq $(X86_EFLAGS_RF|X86_EFLAGS_TF), R11(%rsp)
jnz swapgs_restore_regs_and_return_to_usermode /* → IRET */
/* 모든 검사 통과 → SYSRET 빠른 경로 */
swapgs
sysretq
SYSRET이 비표준(non-canonical) RIP 주소로 복귀할 때 #GP 예외가 Ring 0에서 발생합니다. 이때 RSP는 이미 사용자 스택을 가리키고 있어, 공격자가 제어하는 스택에 커널 데이터가 기록됩니다. Linux 커널은 SYSRET 전에 RIP 주소를 검증하여 이 취약점을 방지합니다.
seccomp BPF 검사 심화
seccomp-BPF 필터는 시스템 콜 진입 경로의 syscall_enter_from_user_mode() 내부에서 실행됩니다. 필터는 cBPF(classic BPF) 바이트코드로 작성되며, 커널이 JIT 컴파일하여 네이티브 코드로 변환합니다. 이 섹션에서는 __seccomp_filter()의 내부 구조와 SECCOMP_RET_* 액션의 실행 경로를 분석합니다.
__seccomp_filter() 내부 흐름
/* kernel/seccomp.c — 핵심 필터 실행 루틴 */
static int __seccomp_filter(int this_syscall,
const struct seccomp_data *sd,
const bool recheck_after_trace)
{
u32 filter_ret, action;
struct seccomp_filter *match = NULL;
int data;
/* 필터 체인 실행: 가장 마지막에 설치된 필터부터 역순 */
filter_ret = seccomp_run_filters(sd, &match);
action = filter_ret & SECCOMP_RET_ACTION_FULL;
data = filter_ret & SECCOMP_RET_DATA;
switch (action) {
case SECCOMP_RET_KILL_PROCESS:
seccomp_log(this_syscall, SIGSYS, action, true);
do_group_exit(SIGSYS); /* 전체 프로세스 종료 */
break;
case SECCOMP_RET_KILL_THREAD:
seccomp_log(this_syscall, SIGSYS, action, true);
do_exit(SIGSYS); /* 해당 스레드만 종료 */
break;
case SECCOMP_RET_TRAP:
/* SIGSYS 시그널 전달 (siginfo에 syscall 정보 포함) */
syscall_set_return_value(current, current_pt_regs(),
-ENOSYS, 0);
force_sig_seccomp(this_syscall, data, false);
goto skip;
case SECCOMP_RET_ERRNO:
/* 지정된 errno 반환 (시스템 콜 실행하지 않음) */
syscall_set_return_value(current, current_pt_regs(),
-data, 0);
goto skip;
case SECCOMP_RET_TRACE:
/* ptrace tracer에 통지: tracer가 syscall 변경 가능 */
if (!ptrace_event_enabled(current, PTRACE_EVENT_SECCOMP))
goto skip;
ptrace_event(PTRACE_EVENT_SECCOMP, data);
break;
case SECCOMP_RET_USER_NOTIF:
/* 사용자 공간 supervisor에 fd로 전달 */
return seccomp_do_user_notification(this_syscall,
match, sd);
case SECCOMP_RET_LOG:
seccomp_log(this_syscall, 0, action, true);
/* fall through → ALLOW */
case SECCOMP_RET_ALLOW:
/* 시스템 콜 정상 진행 */
return 0;
}
...
}
seccomp_data 구조체
BPF 필터에 전달되는 데이터 구조체입니다. 필터는 이 구조체의 필드를 BPF_LD/BPF_ABS로 로드하여 검사합니다.
/* include/uapi/linux/seccomp.h */
struct seccomp_data {
int nr; /* 시스템 콜 번호 */
__u32 arch; /* AUDIT_ARCH_* (아키텍처 식별) */
__u64 instruction_pointer; /* 호출 위치 (syscall 명령어 주소) */
__u64 args[6]; /* 시스템 콜 인자 (arg0~arg5) */
};
arch 필드를 검증해야 합니다. 그렇지 않으면 공격자가 32비트 호환 모드(int 0x80)로 다른 번호 체계의 시스템 콜을 호출하여 필터를 우회할 수 있습니다. 예를 들어 x86_64에서 __NR_write는 1이지만, 32비트에서는 4입니다.
SECCOMP_RET_USER_NOTIF: 사용자 공간 정책
SECCOMP_RET_USER_NOTIF는 Linux 5.0에서 도입된 기능으로, seccomp 필터가 시스템 콜을 사용자 공간의 supervisor 프로세스에 위임합니다. 컨테이너 런타임이 mount(), mknod() 등을 에뮬레이트하는 데 사용됩니다.
/* seccomp_unotif를 사용한 사용자 공간 syscall 핸들링 */
#include <linux/seccomp.h>
/* 1. seccomp 필터 설치 (감시 대상 프로세스) */
int listener_fd = syscall(__NR_seccomp,
SECCOMP_SET_MODE_FILTER,
SECCOMP_FILTER_FLAG_NEW_LISTENER,
&prog);
/* 2. supervisor 프로세스에서 알림 수신 */
struct seccomp_notif *req;
struct seccomp_notif_resp *resp;
seccomp_notify_alloc(&req, &resp);
while (1) {
ioctl(listener_fd, SECCOMP_IOCTL_NOTIF_RECV, req);
/* 시스템 콜 번호와 인자 확인 후 응답 */
resp->id = req->id;
resp->val = 0; /* 반환값 */
resp->error = 0; /* errno (0 = 성공) */
resp->flags = SECCOMP_USER_NOTIF_FLAG_CONTINUE; /* 또는 에뮬레이트 */
ioctl(listener_fd, SECCOMP_IOCTL_NOTIF_SEND, resp);
}
libseccomp 라이브러리를 사용하면 고수준 API로 seccomp 필터를 정의할 수 있습니다. seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(write), 0)처럼 시스템 콜 이름과 액션을 직접 지정합니다. 내부적으로 최적화된 cBPF 프로그램을 생성합니다.
vDSO 메커니즘 심화
vDSO(virtual Dynamic Shared Object)는 커널 모드 전환 없이 특정 시스템 콜을 사용자 공간에서 실행하는 최적화 메커니즘입니다. 이 섹션에서는 gettimeofday()와 clock_gettime()의 vDSO 최적화 내부 구조, vvar 페이지의 업데이트 메커니즘, 그리고 레거시 vsyscall과의 보안/성능 차이를 상세히 비교합니다.
vDSO 매핑 메커니즘
커널은 프로세스 생성 시 ELF 로더가 vDSO를 자동으로 매핑합니다. arch_setup_additional_pages()에서 vDSO ELF 바이너리와 vvar 데이터 페이지를 사용자 주소 공간에 배치합니다.
/* arch/x86/entry/vdso/vma.c */
int arch_setup_additional_pages(struct linux_binprm *bprm, int uses_interp)
{
struct mm_struct *mm = current->mm;
unsigned long vdso_addr;
/* ASLR 적용된 랜덤 주소 선택 */
vdso_addr = get_unmapped_area(NULL, 0,
vdso_image.size + vvar_size,
0, 0);
/* vvar 페이지 매핑 (읽기 전용, 커널이 갱신) */
_install_special_mapping(mm, vdso_addr,
vvar_size,
VM_READ | VM_MAYREAD,
&vvar_mapping);
/* vDSO 코드 매핑 (읽기+실행) */
_install_special_mapping(mm, vdso_addr + vvar_size,
vdso_image.size,
VM_READ | VM_EXEC | VM_MAYREAD | VM_MAYEXEC,
&vdso_mapping);
return 0;
}
/* vvar 페이지 내용: 커널이 타이머 인터럽트마다 업데이트 */
struct vdso_data {
u32 seq; /* seqcount: 읽기 측 일관성 보장 */
s32 clock_mode; /* VDSO_CLOCKMODE_TSC 등 */
u64 cycle_last; /* 마지막 TSC 값 */
u64 mask; /* TSC 마스크 */
u32 mult; /* 사이클 → 나노초 변환 승수 */
u32 shift; /* 나노초 변환 시프트 */
struct vdso_timestamp basetime[VDSO_BASES]; /* 기준 시간 */
s32 tz_minuteswest; /* 시간대 오프셋 */
s32 tz_dsttime; /* DST 정보 */
};
seqcount 기반 일관성 보장
vDSO는 커널과 사용자 공간 사이의 lock-free 데이터 공유를 위해 seqcount 패턴을 사용합니다. 커널(writer)이 데이터를 갱신할 때 시퀀스 번호를 홀수로 만들고, 완료 후 짝수로 만듭니다. 사용자(reader)는 읽기 전후의 시퀀스 번호가 같고 짝수인지 확인합니다.
/* vDSO 시간 읽기: seqcount 기반 lock-free 읽기 */
static int do_hres(const struct vdso_data *vd,
clockid_t clk,
struct __kernel_timespec *ts)
{
const struct vdso_timestamp *vdso_ts = &vd->basetime[clk];
u64 cycles, ns;
u32 seq;
do {
seq = vdso_read_begin(vd); /* seq 홀수이면 재시도 */
if (unlikely(vd->clock_mode == VDSO_CLOCKMODE_NONE))
return -1; /* → syscall 폴백 */
cycles = __arch_get_hw_counter(vd->clock_mode, vd);
ns = (cycles - vd->cycle_last) * vd->mult;
ns >>= vd->shift;
ns += vdso_ts->nsec;
ts->tv_sec = vdso_ts->sec;
ts->tv_nsec = ns;
if (ns >= NSEC_PER_SEC) {
ts->tv_sec++;
ts->tv_nsec -= NSEC_PER_SEC;
}
} while (vdso_read_retry(vd, seq)); /* seq 변경 시 재시도 */
return 0;
}
vsyscall 보안 문제와 에뮬레이션
vsyscall은 vDSO의 전신으로, 고정 주소(0xFFFFFFFFFF600000)에 매핑됩니다. 이 고정 주소는 ASLR을 무력화하여 ROP(Return-Oriented Programming) 공격의 가젯 소스로 악용됩니다. 현재 커널은 vsyscall=emulate(기본)로 접근 시 page fault를 발생시킨 후 커널이 결과를 반환합니다.
| vsyscall 모드 | 부트 파라미터 | 동작 | 보안 |
|---|---|---|---|
| emulate (기본) | vsyscall=emulate |
page fault → 커널 에뮬레이션 | ROP 가젯 제거, 호환성 유지 |
| xonly | vsyscall=xonly |
실행만 허용, 읽기 불가 | 가젯 읽기 방지, 실행은 에뮬레이트 |
| none | vsyscall=none |
완전 비활성화 | 최고 보안, 레거시 바이너리 실행 불가 |
vsyscall=emulate 모드에서 vsyscall 페이지 접근은 page fault → 커널 예외 핸들러 → 결과 반환 순서로 처리되므로, 실제 시스템 콜보다 오히려 느릴 수 있습니다. 모든 현대 배포판의 glibc는 vDSO를 사용하므로 vsyscall은 구형 정적 바이너리 호환용으로만 유지됩니다.
sys_call_table 내부 구조 심화
시스템 콜 테이블의 생성 과정, __x64_sys_* 래퍼 함수의 역할, 그리고 SYSCALL_DEFINE 매크로가 전처리기에 의해 어떻게 확장되는지를 심층 분석합니다. 이 메커니즘을 이해하면 CVE-2009-0029와 같은 레지스터 상위 비트 공격이 왜 발생했고 어떻게 방어되는지 파악할 수 있습니다.
__x64_sys_* 래퍼 함수
Linux 4.17부터 시스템 콜 핸들러는 개별 레지스터 인자 대신 struct pt_regs *를 유일한 인자로 받습니다. __x64_sys_* 래퍼가 pt_regs에서 인자를 추출하여 실제 핸들러에 전달합니다.
/* SYSCALL_DEFINE3(read, ...) 매크로 확장 결과 */
/* 1단계: 실제 구현 함수 (내부) */
static long __se_sys_read(
long __fd, /* unsigned int fd → long으로 수신 */
long __buf, /* char __user *buf → long */
long __count) /* size_t count → long */
{
/* 2단계: long → 실제 타입으로 안전 캐스팅 */
return __do_sys_read(
(unsigned int)__fd, /* 상위 32비트 절단 → CVE-2009-0029 방어 */
(char __user *)__buf,
(size_t)__count);
}
/* 3단계: pt_regs에서 인자 추출하는 래퍼 */
long __x64_sys_read(const struct pt_regs *regs)
{
return __se_sys_read(
regs->di, /* 1번째 인자: rdi */
regs->si, /* 2번째 인자: rsi */
regs->dx); /* 3번째 인자: rdx */
}
/* 4단계: __do_sys_read = 개발자가 작성한 실제 코드 */
static long __do_sys_read(
unsigned int fd,
char __user *buf,
size_t count)
{
return ksys_read(fd, buf, count);
}
unsigned int fd를 rdi(64비트)로 전달할 때, 상위 비트에 의도치 않은 값이 들어올 수 있습니다. __se_sys_* 래퍼가 long으로 받아 명시적 캐스팅하여 이를 방지합니다.
sys_call_table 생성 과정
시스템 콜 테이블은 빌드 시 여러 단계의 코드 생성을 거쳐 완성됩니다.
/* 생성된 asm/syscalls_64.h (빌드 결과) */
__SYSCALL(0, sys_read)
__SYSCALL(1, sys_write)
__SYSCALL(2, sys_open)
__SYSCALL(3, sys_close)
/* ... */
__SYSCALL(435, sys_clone3)
/* arch/x86/entry/syscall_64.c */
#define __SYSCALL(nr, sym) [nr] = __x64_##sym,
const sys_call_ptr_t sys_call_table[__NR_syscall_max + 1] = {
/* 미구현 번호는 sys_ni_syscall로 초기화 */
[0 ... __NR_syscall_max] = __x64_sys_ni_syscall,
/* 이 include가 실제 핸들러로 덮어씀 */
#include <asm/syscalls_64.h>
};
/* 결과: [0]=__x64_sys_read, [1]=__x64_sys_write, ... */
static_call()을 사용하여 간접 함수 호출(sys_call_table[nr](regs))을 직접 호출로 변환할 수 있습니다. 이는 Spectre v2의 간접 분기 예측 주입 공격을 원천 차단합니다. objtool이 빌드 시 간접 호출을 검증합니다.
ptrace/seccomp/audit 추적 훅
시스템 콜 진입과 반환 시점에는 여러 보안 및 추적 훅이 실행됩니다. syscall_enter_from_user_mode()와 syscall_exit_to_user_mode()에서 ptrace, seccomp, audit 서브시스템이 순서대로 호출되며, 각 훅이 시스템 콜 동작을 변경하거나 차단할 수 있습니다.
진입/반환 훅 실행 순서
syscall_work 플래그 검사
/* kernel/entry/common.c */
static long syscall_enter_from_user_mode_work(
struct pt_regs *regs, long syscall)
{
unsigned long work = READ_ONCE(current_thread_info()->syscall_work);
if (work & SYSCALL_WORK_SECCOMP) {
/* seccomp 필터 실행 — 차단 시 syscall = -1 */
syscall = __seccomp_filter(syscall, NULL, false);
if (syscall == -1)
return -1; /* 시스템 콜 실행 건너뜀 */
}
if (work & SYSCALL_WORK_SYSCALL_TRACE) {
/* ptrace tracer에 진입 통지 */
ptrace_report_syscall_entry(regs);
/* tracer가 regs->orig_ax를 변경하면 다른 syscall 실행 */
syscall = regs->orig_ax;
}
if (work & SYSCALL_WORK_SYSCALL_AUDIT) {
/* audit 레코드 생성 */
audit_syscall_entry(syscall,
regs->di, regs->si, regs->dx, regs->r10);
}
if (work & SYSCALL_WORK_SYSCALL_EMU) {
/* ptrace SYSEMU: 시스템 콜 에뮬레이트 (UML 등) */
return -1;
}
return syscall;
}
ptrace를 통한 시스템 콜 변경
ptrace tracer는 PTRACE_SYSCALL로 대상 프로세스의 시스템 콜 진입/반환을 가로채고, PTRACE_SET_SYSCALL이나 레지스터 수정으로 시스템 콜 번호와 인자를 변경할 수 있습니다. strace는 이 메커니즘으로 동작합니다.
/* ptrace를 이용한 시스템 콜 모니터링 예시 */
#include <sys/ptrace.h>
#include <sys/wait.h>
#include <sys/user.h>
void trace_child(pid_t child)
{
int status;
struct user_regs_struct regs;
waitpid(child, &status, 0);
ptrace(PTRACE_SYSCALL, child, 0, 0); /* 시작 */
while (1) {
waitpid(child, &status, 0);
if (WIFEXITED(status)) break;
/* 시스템 콜 진입 시점: 레지스터 읽기 */
ptrace(PTRACE_GETREGS, child, 0, ®s);
printf("syscall %lld(0x%llx, 0x%llx, 0x%llx)\n",
regs.orig_rax, regs.rdi, regs.rsi, regs.rdx);
/* 시스템 콜 번호를 변경하여 차단 */
if (regs.orig_rax == __NR_unlink) {
regs.orig_rax = -1; /* 유효하지 않은 번호 → -ENOSYS */
ptrace(PTRACE_SETREGS, child, 0, ®s);
}
ptrace(PTRACE_SYSCALL, child, 0, 0);
}
}
find / -name '*.c'를 추적하면 100배 이상 느려질 수 있습니다. 프로덕션 환경에서는 ftrace tracepoint나 eBPF를 사용하세요.
ARM64 시스템 콜 심화
ARM64(AArch64) 아키텍처의 시스템 콜 메커니즘을 x86_64와 비교하면서 심층 분석합니다. SVC #0 명령어의 동작, el0_svc 핸들러의 내부 구조, 예외 벡터 테이블의 레이아웃, 그리고 32비트 AArch32 호환 계층을 다룹니다.
ARM64 예외 벡터 테이블
ARM64에서 SVC #0은 동기 예외(Synchronous Exception)를 발생시킵니다. CPU는 VBAR_EL1 레지스터가 가리키는 예외 벡터 테이블에서 적절한 핸들러를 찾습니다. 벡터 테이블은 4개 소스(같은/다른 EL, AArch64/AArch32) x 4개 예외 유형(동기/IRQ/FIQ/SError)으로 16개 엔트리로 구성됩니다.
/* arch/arm64/kernel/entry.S — 예외 벡터 테이블 */
.align 11 /* 2048바이트 정렬 */
SYM_CODE_START(vectors)
/* ===== 같은 EL, SP_EL0 사용 (커널에서 미사용) ===== */
kernel_ventry el1_sync_invalid /* 동기 예외 */
kernel_ventry el1_irq_invalid /* IRQ */
kernel_ventry el1_fiq_invalid /* FIQ */
kernel_ventry el1_error_invalid /* SError */
/* ===== 같은 EL, SP_ELx 사용 (커널 내부 예외) ===== */
kernel_ventry el1h_64_sync /* 커널 동기 예외 */
kernel_ventry el1h_64_irq /* 커널 IRQ */
kernel_ventry el1h_64_fiq
kernel_ventry el1h_64_error
/* ===== 다른 EL (EL0), AArch64 모드 ===== */
kernel_ventry el0t_64_sync /* ★ 64비트 시스템 콜 진입 */
kernel_ventry el0t_64_irq
kernel_ventry el0t_64_fiq
kernel_ventry el0t_64_error
/* ===== 다른 EL (EL0), AArch32 모드 ===== */
kernel_ventry el0t_32_sync /* 32비트 호환 시스템 콜 */
kernel_ventry el0t_32_irq
kernel_ventry el0t_32_fiq
kernel_ventry el0t_32_error
SYM_CODE_END(vectors)
ARM64 PAN과 사용자 메모리 접근
ARM64의 PAN(Privileged Access Never)은 x86의 SMAP에 해당하는 기능으로, EL1(커널)에서 EL0(사용자) 메모리에 대한 직접 접근을 차단합니다. copy_to_user() 등에서 uaccess_enable()/uaccess_disable()로 일시적으로 해제합니다.
/* arch/arm64/include/asm/uaccess.h */
static inline void __uaccess_enable_hw_pan(void)
{
/* PSTATE.PAN 비트 클리어 → 사용자 메모리 접근 허용 */
asm("msr pstate_pan, #0");
}
static inline void __uaccess_disable_hw_pan(void)
{
/* PSTATE.PAN 비트 설정 → 사용자 메모리 접근 차단 */
asm("msr pstate_pan, #1");
}
/* ARM64 copy_from_user 내부 흐름 */
unsigned long __arch_copy_from_user(
void *to, const void __user *from, unsigned long n)
{
__uaccess_enable_hw_pan(); /* PAN 해제 */
n = __raw_copy_from_user(to, from, n);
__uaccess_disable_hw_pan(); /* PAN 복원 */
return n;
}
AArch32 호환 계층 상세
ARM64 커널의 CONFIG_COMPAT 옵션은 32비트 ARM 바이너리를 실행할 수 있게 합니다. 32비트 시스템 콜은 SWI #0(Software Interrupt) 명령어를 사용하며, 별도의 compat_sys_call_table을 통해 디스패치됩니다.
| 항목 | AArch64 (native) | AArch32 (compat) |
|---|---|---|
| 호출 명령어 | SVC #0 |
SWI #0 |
| 번호 레지스터 | x8 |
r7 |
| 인자 레지스터 | x0~x5 |
r0~r5 |
| 반환 레지스터 | x0 |
r0 |
| 예외 벡터 오프셋 | VBAR_EL1 + 0x400 |
VBAR_EL1 + 0x600 |
| 시스템 콜 테이블 | sys_call_table |
compat_sys_call_table |
| write 번호 | 64 | 4 |
el0t_64_sync 진입점을 사용합니다.
ftrace 시스템 콜 추적 심화
ftrace의 시스템 콜 tracepoint는 커널 내부에서 가장 가벼운 시스템 콜 추적 메커니즘입니다. SYSCALL_METADATA 매크로가 생성한 메타데이터를 기반으로 sys_enter_*/sys_exit_* tracepoint가 자동 생성됩니다. strace(ptrace 기반)와 달리 대상 프로세스를 중단시키지 않으며, eBPF/bpftrace와 결합하면 프로덕션 환경에서도 안전하게 사용할 수 있습니다.
strace 내부 동작 분석
strace는 ptrace(PTRACE_SYSCALL)을 사용하여 대상 프로세스의 시스템 콜을 추적합니다. 매 시스템 콜마다 프로세스가 2번 중단(진입+반환)되므로 오버헤드가 매우 큽니다.
/* strace 내부 동작 순서 (간략화) */
/* 1. 대상 프로세스 attach */
ptrace(PTRACE_ATTACH, target_pid, 0, 0);
waitpid(target_pid, &status, 0);
/* 2. PTRACE_O_TRACESYSGOOD 옵션 설정 */
ptrace(PTRACE_SETOPTIONS, target_pid,
0, PTRACE_O_TRACESYSGOOD);
/* 3. 추적 루프 */
while (1) {
/* 다음 시스템 콜 진입/반환까지 실행 */
ptrace(PTRACE_SYSCALL, target_pid, 0, 0);
waitpid(target_pid, &status, 0);
if (WIFSTOPPED(status) &&
WSTOPSIG(status) == (SIGTRAP | 0x80)) {
/* 시스템 콜 진입: 번호와 인자 읽기 */
ptrace(PTRACE_GETREGSET, target_pid,
NT_PRSTATUS, &iov);
/* → regs.orig_rax = syscall number */
/* → regs.rdi, rsi, rdx, r10, r8, r9 = args */
/* 다시 실행하여 반환 대기 */
ptrace(PTRACE_SYSCALL, target_pid, 0, 0);
waitpid(target_pid, &status, 0);
/* 시스템 콜 반환: 반환값 읽기 */
ptrace(PTRACE_GETREGSET, target_pid,
NT_PRSTATUS, &iov);
/* → regs.rax = return value */
}
}
ftrace tracepoint 활용 상세
# ===== ftrace 시스템 콜 추적 실전 활용 =====
# 1. 사용 가능한 시스템 콜 tracepoint 목록 확인
$ ls /sys/kernel/tracing/events/syscalls/ | head -20
sys_enter_accept
sys_enter_accept4
sys_enter_access
sys_enter_acct
...
sys_exit_write
sys_exit_writev
# 2. 특정 시스템 콜 추적 활성화
$ cd /sys/kernel/tracing
$ echo 0 > tracing_on
$ echo > trace
$ echo 1 > events/syscalls/sys_enter_openat/enable
$ echo 1 > events/syscalls/sys_exit_openat/enable
# 3. PID 필터 설정 (특정 프로세스만 추적)
$ echo 'common_pid == 1234' > \
events/syscalls/sys_enter_openat/filter
# 4. 추적 시작 및 결과 확인
$ echo 1 > tracing_on
$ cat trace_pipe
nginx-1234 [003] .... 12345.678901: sys_openat(
dfd: 0xffffff9c, filename: 0x7f3a..., flags: 0x80000,
mode: 0x0)
nginx-1234 [003] .... 12345.678950: sys_openat -> 0x5
# 5. function_graph와 결합: 시스템 콜 내부 함수 호출 트리
$ echo function_graph > current_tracer
$ echo do_syscall_64 > set_graph_function
$ echo 1 > tracing_on
$ cat trace
3) | do_syscall_64() {
3) | do_syscall_x64() {
3) | __x64_sys_openat() {
3) | do_sys_openat2() {
3) 0.890 us | getname_flags();
3) | do_filp_open() {
3) 12.345 us | }
3) 14.567 us | }
3) 15.123 us | }
3) 15.890 us | }
3) 16.234 us | }
bpftrace 고급 시스템 콜 분석
# ===== bpftrace 시스템 콜 분석 고급 예제 =====
# 1. 시스템 콜별 레이턴시 히스토그램 (프로세스 이름 포함)
$ sudo bpftrace -e '
tracepoint:raw_syscalls:sys_enter {
@start[tid] = nsecs;
@nr[tid] = args->id;
}
tracepoint:raw_syscalls:sys_exit /@start[tid]/ {
$lat = (nsecs - @start[tid]) / 1000;
@us[@nr[tid]] = hist($lat);
delete(@start[tid]);
delete(@nr[tid]);
}'
# 2. 프로세스별 시스템 콜 빈도 Top-N (5초 동안)
$ sudo bpftrace -e '
tracepoint:raw_syscalls:sys_enter {
@[comm, args->id] = count();
}
interval:s:5 {
print(@, 20);
clear(@);
}'
# 3. 특정 파일에 대한 read/write 추적
$ sudo bpftrace -e '
tracepoint:syscalls:sys_enter_openat
/str(args->filename) == "/etc/passwd"/ {
printf("PID %d (%s) opening /etc/passwd\n", pid, comm);
@fd[tid] = 1;
}
tracepoint:syscalls:sys_enter_read /@fd[tid]/ {
printf(" read(%d, buf, %d)\n", args->fd, args->count);
}'
# 4. 시스템 콜 에러 분석 (음수 반환값)
$ sudo bpftrace -e '
tracepoint:raw_syscalls:sys_exit
/args->ret < 0/ {
@errors[comm, args->id, - args->ret] = count();
}
END { print(@errors, 20); }'
strace -f가 가장 간편합니다. 프로덕션 모니터링에는 perf trace를 사용하세요. 복잡한 필터링과 집계가 필요하면 bpftrace가 최적입니다. 커널 내부 함수 호출 트리를 보려면 ftrace function_graph를 사용하세요.
시스템 콜 오버헤드와 성능 최적화
시스템 콜의 오버헤드는 하드웨어(모드 전환, 캐시/TLB 영향)와 소프트웨어(보안 완화, 추적 훅) 양쪽에서 발생합니다. 이 섹션에서는 Spectre/Meltdown 완화가 시스템 콜 성능에 미치는 영향을 정량적으로 분석하고, vDSO, io_uring, 배치 시스템 콜 등의 최적화 기법을 비교합니다.
시스템 콜 오버헤드 구성 요소
| 구성 요소 | 비용 (사이클) | 비용 (ns, 3GHz 기준) | 설명 |
|---|---|---|---|
| SYSCALL/SYSRET 하드웨어 | ~50 | ~17 | CPL 전환, MSR 읽기, RFLAGS 마스킹 |
| swapgs + 스택 전환 | ~30 | ~10 | GS base 교환, per-CPU 영역 접근 |
| pt_regs 저장/복원 | ~60 | ~20 | 15개 레지스터 push/pop |
| KPTI (CR3 전환) | ~100~300 | ~33~100 | 페이지 테이블 전환, TLB 영향 |
| PCID 지원 시 KPTI | ~30~50 | ~10~17 | TLB flush 불필요 |
| Spectre v1 (array_index_nospec) | ~5 | ~2 | LFENCE 또는 조건부 마스킹 |
| Spectre v2 (RETPOLINE) | ~10~30 | ~3~10 | 간접 점프를 ret로 변환 |
| IBRS/eIBRS | ~20~150 | ~7~50 | MSR 쓰기 (eIBRS는 진입 시 1번) |
| seccomp BPF (설치 시) | ~50~200 | ~17~67 | BPF 프로그램 실행 (JIT 컴파일됨) |
| audit (활성 시) | ~100~500 | ~33~167 | 감사 레코드 생성, 링버퍼 쓰기 |
| ptrace (활성 시) | ~10000+ | ~3333+ | 2번의 컨텍스트 스위치 |
Spectre 완화 기법별 성능 영향
# ===== 보안 완화 기법별 성능 측정 =====
# 1. 현재 활성화된 완화 기법 확인
$ grep . /sys/devices/system/cpu/vulnerabilities/*
meltdown:Mitigation: PTI
spectre_v1:Mitigation: usercopy/swapgs barriers
spectre_v2:Mitigation: Enhanced IBRS; IBPB: conditional; RSB filling
spec_store_bypass:Mitigation: Speculative Store Bypass disabled
retbleed:Not affected
# 2. getpid() 마이크로벤치마크 (null syscall)
$ cat bench_syscall.c
#include <unistd.h>
#include <time.h>
#include <stdio.h>
#include <sys/syscall.h>
int main() {
struct timespec t1, t2;
int N = 10000000;
clock_gettime(CLOCK_MONOTONIC, &t1);
for (int i = 0; i < N; i++)
syscall(__NR_getpid);
clock_gettime(CLOCK_MONOTONIC, &t2);
double ns = ((t2.tv_sec - t1.tv_sec) * 1e9 +
(t2.tv_nsec - t1.tv_nsec)) / N;
printf("getpid(): %.1f ns/call\n", ns);
}
# 3. 완화 기법 비활성화 후 비교 (테스트 환경만!)
# GRUB_CMDLINE_LINUX="mitigations=off" # 모든 완화 비활성화
# 주의: 프로덕션에서 절대 사용 금지!
# 4. 전형적인 결과 비교
# mitigations=off: getpid() ~80ns
# mitigations=auto: getpid() ~180ns (KPTI + eIBRS)
# KPTI + IBRS: getpid() ~250ns
# KPTI + Retpoline: getpid() ~200ns
배치 시스템 콜과 syscall 최소화
시스템 콜 오버헤드를 줄이는 핵심 전략은 호출 횟수를 줄이는 것입니다. Linux 커널은 여러 가지 배치 메커니즘을 제공합니다.
| 기법 | 설명 | syscall 절감 | 적용 사례 |
|---|---|---|---|
| io_uring | 공유 링버퍼로 I/O 요청 배치 | N개 I/O → 1 syscall | 고성능 저장소, 네트워크 서버 |
| vDSO | 커널 진입 없이 사용자 공간 실행 | syscall 0개 | 시간 조회, getcpu |
| sendmmsg/recvmmsg | 여러 메시지를 한 번에 송수신 | N개 메시지 → 1 syscall | UDP 서버, DNS |
| preadv2/pwritev2 | 여러 버퍼의 scatter/gather I/O | N개 read/write → 1 syscall | 데이터베이스, 로그 서버 |
| splice/tee | 커널 내 zero-copy 데이터 이동 | read+write → 1 syscall | 프록시 서버, 파이프라인 |
| epoll | 이벤트 기반 다중 fd 모니터링 | N개 poll → 1 syscall | 이벤트 루프, 웹 서버 |
| io_uring SQPOLL | 커널 폴링 스레드가 SQ 처리 | syscall 0개 (커널 폴링) | 극한 저지연 I/O |
io_uring SQPOLL: 시스템 콜 제거
/* io_uring SQPOLL 모드: 시스템 콜 없이 I/O 처리 */
#include <liburing.h>
struct io_uring ring;
struct io_uring_params params = {
.flags = IORING_SETUP_SQPOLL, /* 커널 폴링 스레드 활성화 */
.sq_thread_idle = 2000, /* 유휴 2초 후 스레드 정지 */
};
io_uring_queue_init_params(256, &ring, ¶ms);
/* SQE(Submission Queue Entry) 제출: 시스템 콜 불필요! */
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, fd, buf, len, offset);
io_uring_submit(&ring);
/* → 커널 폴링 스레드가 SQ를 감시하여 자동 처리 */
/* → 사용자 공간에서 CQ(Completion Queue)를 폴링하여 결과 확인 */
/* CQE(Completion Queue Entry) 수확 */
struct io_uring_cqe *cqe;
io_uring_peek_cqe(&ring, &cqe);
if (cqe) {
int result = cqe->res; /* I/O 결과 */
io_uring_cqe_seen(&ring, cqe);
}
/* 전체 I/O 루프에서 시스템 콜 0개 달성 가능 */
시스템 콜 최적화 기법 비교
- 시간 조회(
gettimeofday,clock_gettime)는 vDSO가 자동으로 최적화합니다 (추가 작업 불필요). - 고빈도 I/O는
io_uring으로 배치 처리하세요. NVMe SSD에서 2배 이상의 IOPS 향상을 기대할 수 있습니다. - UDP 서버에서는
sendmmsg()/recvmmsg()를 사용하세요. - 프록시/파이프라인에서는
splice()로 사용자 공간 복사를 제거하세요. mitigations=off는 절대 프로덕션에서 사용하지 마세요. PCID 지원 CPU를 사용하면 KPTI 오버헤드를 최소화할 수 있습니다.
관련 문서
시스템 콜과 관련된 다른 주제를 더 깊이 이해하고 싶다면 다음 문서를 참고하세요.