시스템 콜 (System Call)
사용자 공간(User Space)과 커널 공간(Kernel Space)을 잇는 핵심 인터페이스인 시스템 콜을 아키텍처별 진입 경로부터 반환까지 추적합니다. x86_64 SYSCALL/SYSRET, ARM64 SVC, entry_SYSCALL_64 디스패치(Dispatch), SYSCALL_DEFINE 매크로(Macro) 전개, 사용자 메모리 접근 검증, vDSO 경량 경로, seccomp/ptrace/audit 정책 훅, compat ABI 및 restart 경로까지 실무 중심으로 상세히 다룹니다.
핵심 요약
- 시스템 콜 — 사용자 프로그램이 커널 서비스를 요청하는 유일한 공식 인터페이스입니다.
- SYSCALL/SYSRET — x86_64에서 사용자↔커널 전환을 수행하는 CPU 명령어입니다.
- sys_call_table — 시스템 콜 번호를 실제 커널 함수에 매핑(Mapping)하는 함수 포인터 테이블입니다.
- vDSO — 커널 진입 없이 사용자 공간에서 실행되는 가상 동적 공유 객체 (gettimeofday 등).
- seccomp-BPF — 프로세스가 사용할 수 있는 시스템 콜을 BPF 필터로 제한하는 보안 기능입니다.
- ARM64 SVC #0 — ARM64 아키텍처에서
SVC #0명령어와x8레지스터(Register)로 시스템 콜을 호출합니다 (x86_64의SYSCALL/rax에 해당). - KPTI — Meltdown(CVE-2017-5754) 완화를 위해 사용자/커널 페이지 테이블(Page Table)을 분리하는 기법으로, 1~30%의 성능 오버헤드(Overhead)가 발생합니다.
- copy_to_user / access_ok — 커널에서 사용자 메모리에 안전하게 접근하기 위한 필수 함수로, SMAP 하드웨어 보호와 협력합니다.
단계별 이해
- 호출 흐름 — 사용자 프로그램 → glibc 래퍼 →
SYSCALL명령어 → 커널 진입 → 핸들러(Handler) 실행 → 결과 반환.이 전체 과정이 수백 나노초 안에 완료됩니다.
- strace로 관찰 —
strace ls를 실행하면ls가 호출하는 모든 시스템 콜을 볼 수 있습니다.open(),read(),write()등 실제 시스템 콜과 그 인자/반환값을 확인합니다. - 커널 내부 —
SYSCALL_DEFINE3(read, ...)매크로가sys_read()함수를 정의합니다.시스템 콜 번호는
arch/x86/entry/syscalls/syscall_64.tbl에 정의되어 있습니다. - 보안 — seccomp-BPF로 컨테이너(Container)나 샌드박스(Sandbox)에서 허용할 시스템 콜을 화이트리스트로 제한합니다.
Docker, Chrome 등이 seccomp을 사용하여 공격 표면을 줄입니다.
- 성능 측정 —
perf stat -e syscalls:sys_enter_read로 시스템 콜 횟수를 측정하고,bpftrace로 레이턴시 분포를 확인합니다.vDSO는 커널 진입 없이 실행되어
clock_gettime을 3~10배 빠르게 만듭니다. io_uring은 배치 처리로 syscall 오버헤드를 줄입니다. - 보안 취약점(Vulnerability) 분석 — Meltdown(CVE-2017-5754)은 시스템 콜 경계를 통해 커널 메모리를 읽었고, KPTI 패치(Patch)가 이를 해결했습니다.
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 | 사용자 공간 메모리만 (가상 주소(Virtual Address) 하위 영역) |
int 0x80에서 SYSCALL까지의 변천
x86 시스템 콜 호출 메커니즘은 성능 향상을 위해 발전해 왔습니다.
| 메커니즘 | 아키텍처 | 도입 시기 | 특징 |
|---|---|---|---|
int 0x80 |
i386 | 초기 | 소프트웨어 인터럽트(Interrupt), 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에 미리 저장된 진입점(Entry Point)으로 직접 점프하므로 훨씬 빠릅니다.
/* 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 /* 빠른 시스템 콜 진입 */
glibc 시스템 콜 래퍼 분석
사용자 프로그램에서 read(), write() 같은 함수를 호출하면, 이 함수들은 glibc(GNU C Library)가 제공하는 래퍼(Wrapper) 함수입니다. glibc 래퍼는 시스템 콜 번호를 레지스터에 설정하고, syscall 명령어를 실행한 뒤, 커널이 반환한 값을 검사하여 에러 시 errno를 설정하는 역할을 합니다.
INTERNAL_SYSCALL / INLINE_SYSCALL 매크로
glibc는 인라인 어셈블리를 사용하여 시스템 콜을 실행합니다. 핵심 매크로는 sysdeps/unix/sysv/linux/x86_64/sysdep.h에 정의되어 있습니다.
/* glibc sysdeps/unix/sysv/linux/x86_64/sysdep.h (단순화) */
/* INTERNAL_SYSCALL: 원시 반환값(음수 에러 코드 포함)을 그대로 반환 */
#define INTERNAL_SYSCALL(name, nr, args...) \
({ unsigned long _resultvar; \
LOAD_ARGS_##nr(args) \
LOAD_REGS_##nr \
asm volatile ( \
"syscall\n\t" \
: "=a" (_resultvar) \
: "0" (__NR_##name) ASM_ARGS_##nr \
: "memory", "cc", "r11", "cx" \
); \
(long) _resultvar; })
/* INLINE_SYSCALL: 에러 시 errno 설정 + -1 반환 */
#define INLINE_SYSCALL(name, nr, args...) \
({ long _ret = INTERNAL_SYSCALL(name, nr, args); \
if (INTERNAL_SYSCALL_ERROR_P(_ret)) { \
__set_errno(INTERNAL_SYSCALL_ERRNO(_ret)); \
_ret = -1; \
} \
_ret; })
/* 에러 판별: 반환값이 -4095 ~ -1 범위이면 에러 */
#define INTERNAL_SYSCALL_ERROR_P(val) \
((unsigned long) (val) >= (unsigned long) -4095)
syscall 명령어는 rcx(반환 주소)와 r11(RFLAGS 저장)을 덮어씁니다. 따라서 인라인 어셈블리에서 이 두 레지스터를 clobber로 선언합니다. 이것이 시스템 콜이 C 호출 규약의 rcx 대신 r10을 4번째 인자에 사용하는 이유입니다.
read() 래퍼의 전체 경로
사용자가 read(fd, buf, count)를 호출했을 때 glibc 내부에서 일어나는 과정입니다.
/* glibc io/read.c (단순화) */
ssize_t
__libc_read (int fd, void *buf, size_t nbytes)
{
return SYSCALL_CANCEL (read, fd, buf, nbytes);
}
libc_hidden_def (__libc_read)
weak_alias (__libc_read, read)
/* SYSCALL_CANCEL 매크로 확장 (단순화):
* 1. pthread 취소 지점(Cancellation Point) 검사
* 2. INLINE_SYSCALL 실행
* 3. 취소 요청 시 __pthread_unwind() 호출
*/
#define SYSCALL_CANCEL(name, args...) \
({ long _ret; \
int _cancel_oldtype = LIBC_CANCEL_ASYNC(); \
_ret = INLINE_SYSCALL(name, 3, args); \
LIBC_CANCEL_RESET(_cancel_oldtype); \
_ret; })
Cancellation Point와 시스템 콜
POSIX는 특정 함수(read, write, sleep 등)를 취소 지점(Cancellation Point)으로 지정합니다. pthread_cancel()로 스레드 취소 요청이 있을 때, 해당 스레드가 취소 지점에 도달하면 실제로 취소됩니다.
/* 취소 지점의 동작 과정 */
1. pthread_cancel(target_thread) /* 취소 요청 설정 */
2. target_thread가 read() 호출 /* 취소 지점 진입 */
3. LIBC_CANCEL_ASYNC() /* 비동기 취소 활성화 */
4. INLINE_SYSCALL(read, ...) /* 실제 시스템 콜 실행 */
5. LIBC_CANCEL_RESET() /* 취소 검사 및 unwind */
syscall() 범용 래퍼
syscall() 함수는 glibc에 전용 래퍼가 없는 시스템 콜이나 새로 추가된 시스템 콜을 직접 호출할 때 사용합니다.
#include <sys/syscall.h>
#include <unistd.h>
/* glibc 전용 래퍼가 있는 경우 */
ssize_t n = read(fd, buf, count); /* 권장 */
/* syscall() 범용 래퍼 사용 */
long n = syscall(SYS_read, fd, buf, count); /* 동일하지만 취소 지점 아님 */
/* glibc 래퍼가 없는 시스템 콜 (예: pidfd_open, Linux 5.3+) */
int pidfd = syscall(SYS_pidfd_open, pid, 0);
/* io_uring_setup (glibc 래퍼 없음, liburing 사용 권장) */
int ring_fd = syscall(__NR_io_uring_setup, entries, ¶ms);
syscall()은 POSIX 취소 지점이 아닙니다. 또한 전용 래퍼가 제공하는 추가 검증(예: open()에서의 variadic 인자 처리)이 없습니다. glibc 래퍼가 존재하는 시스템 콜에는 반드시 전용 래퍼를 사용하세요.
시스템 콜 에러 처리와 errno 변환
커널 시스템 콜 핸들러는 성공 시 0 이상의 값을, 실패 시 음수 에러 코드(예: -EFAULT, -EINVAL)를 반환합니다. 이 음수 값은 rax 레지스터를 통해 사용자 공간으로 전달되며, glibc가 이를 errno로 변환합니다.
커널 내부 에러 코드 규약
커널에서 에러 코드는 -1부터 -MAX_ERRNO(-4095)까지의 음수 값입니다. 포인터 반환 함수에서는 ERR_PTR()/IS_ERR()/PTR_ERR() 매크로를 사용합니다.
/* include/linux/err.h */
#define MAX_ERRNO 4095
#define IS_ERR_VALUE(x) \
unlikely((unsigned long)(x) >= (unsigned long)-MAX_ERRNO)
static inline void * ERR_PTR(long error)
{
return (void *) error;
}
static inline long PTR_ERR(const void *ptr)
{
return (long) ptr;
}
static inline bool IS_ERR(const void *ptr)
{
return IS_ERR_VALUE((unsigned long) ptr);
}
/* 사용 예: 파일 열기 */
struct file *f = filp_open("/etc/passwd", O_RDONLY, 0);
if (IS_ERR(f)) {
int err = PTR_ERR(f); /* err = -EACCES 등 */
return err;
}
0x00007FFFFFFFFFFF이므로, 0xFFFFFFFFFFFFF001(-4095) ~ 0xFFFFFFFFFFFFFFFF(-1) 범위는 유효한 포인터가 될 수 없습니다. 이 범위를 에러 코드로 안전하게 사용할 수 있습니다.
glibc의 errno 변환 과정
커널이 SYSRET로 반환한 후, glibc는 rax 값을 검사하여 에러 여부를 판단합니다.
/* glibc의 에러 변환 과정 (의사 코드) */
/* 1. 커널이 rax에 반환값 저장 (예: -14 = -EFAULT) */
long result = /* syscall 명령어의 rax 값 */;
/* 2. INTERNAL_SYSCALL_ERROR_P: 에러 판별 */
if ((unsigned long) result >= (unsigned long) -4095) {
/* 3. errno 설정: 양수로 변환 */
errno = -result; /* errno = 14 (EFAULT) */
/* 4. 사용자에게 -1 반환 */
return -1;
}
/* 5. 성공 시 결과를 그대로 반환 */
return result;
/* 따라서 사용자 코드에서: */
ssize_t n = read(fd, buf, count);
if (n == -1) {
perror("read"); /* errno 기반 메시지 출력 */
/* errno == EFAULT, EINVAL, EIO 등 */
}
주요 errno 코드 분류
| errno | 값 | 의미 | 발생 시나리오 |
|---|---|---|---|
EPERM | 1 | 권한 없음 | 비특권 프로세스가 특권 작업 시도 (예: kill(1, SIGKILL)) |
ENOENT | 2 | 파일 없음 | open()에 존재하지 않는 경로 전달 |
ESRCH | 3 | 프로세스 없음 | kill()에 존재하지 않는 PID 전달 |
EINTR | 4 | 시그널에 의한 중단 | 시스템 콜 실행 중 시그널 수신 (SA_RESTART 미설정) |
EIO | 5 | I/O 오류 | 디스크 읽기/쓰기 하드웨어 오류 |
EBADF | 9 | 잘못된 파일 디스크립터 | 닫힌 fd로 read()/write() 호출 |
EAGAIN | 11 | 재시도 필요 | 논블로킹 I/O에서 데이터 미준비 |
ENOMEM | 12 | 메모리 부족 | mmap() 시 가용 메모리 소진 |
EACCES | 13 | 접근 거부 | 읽기 권한 없는 파일에 open(O_RDONLY) |
EFAULT | 14 | 잘못된 주소 | read(fd, NULL, n) — NULL 포인터 전달 |
EBUSY | 16 | 장치 사용 중 | 마운트된 파일 시스템에 umount() |
EEXIST | 17 | 이미 존재 | mkdir()에 기존 디렉터리 경로 |
EINVAL | 22 | 잘못된 인자 | lseek(fd, 0, 99) — 잘못된 whence 값 |
ENOSPC | 28 | 공간 부족 | 디스크 가용 공간 소진 시 write() |
EPIPE | 32 | 파이프 끊김 | 읽는 쪽이 닫힌 파이프에 write() |
ENOSYS | 38 | 미구현 시스템 콜 | 존재하지 않는 시스템 콜 번호 호출 |
ENOTSUP | 95 | 미지원 연산 | 파일 시스템이 지원하지 않는 연산 |
ETIMEDOUT | 110 | 시간 초과 | 소켓 연결 시간 초과 |
EAGAIN과 EWOULDBLOCK은 같은 값(11)입니다. POSIX는 이 두 코드가 다를 수 있다고 명시하지만, Linux에서는 동일합니다. 이식성을 위해 if (errno == EAGAIN || errno == EWOULDBLOCK)으로 검사하는 것이 권장됩니다.
시스템 콜 아키텍처
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을 사용합니다. 어셈블리(Assembly) 종합 페이지(Page)에서 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으로 선언되어 부팅 후에는 읽기 전용(Read-Only)이 됩니다. 이는 루트킷이 시스템 콜 테이블을 변조하는 것을 방지하는 보안 장치입니다.
시스템 콜 번호 할당
시스템 콜 번호는 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_DEFINE<em>N</em> 매크로로 정의됩니다. 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);
}
코드 설명
- SYSCALL_DEFINE3(read, ...)인자가 3개인
read시스템 콜을 정의합니다. 이 매크로는 내부적으로__x64_sys_read,__se_sys_read,__do_sys_read세 함수와 ftrace tracepoint 메타데이터를 모두 생성합니다. - char __user *, buf
__user어노테이션(annotation)은 포인터가 사용자 공간 주소임을 표시합니다. 이 표시가 없으면 Sparse 정적 분석 도구가 경고를 발생시킵니다. 이 포인터에 직접 접근하면 안 되며,copy_to_user()/copy_from_user()를 사용해야 합니다. - ksys_read(fd, buf, count)
ksys_접두사 함수는 시스템 콜 래퍼 없이 커널 내부에서 직접 호출할 수 있는 "커널 전용" 인터페이스입니다. 커널 초기화 코드나 다른 서브시스템에서 시스템 콜 기능을 재사용할 때 활용합니다. - SYSCALL_DEFINE0(getpid)인자가 없는 시스템 콜입니다.
task_tgid_vnr(current)는 현재 태스크(task)의 스레드 그룹 ID(Thread Group ID, PID)를 네임스페이스(Namespace) 상대적으로 반환합니다. - SYSCALL_DEFINE6(mmap, ...)인자 6개의 최대치 예시입니다. x86_64 ABI는 rdi·rsi·rdx·r10·r8·r9 레지스터로 인자를 전달하므로 6개가 상한입니다. 참고로
int 0x80(32비트 ABI)에서는 ebx·ecx·edx·esi·edi·ebp 6개를 사용합니다. - off >> PAGE_SHIFT파일 오프셋을 바이트 단위에서 페이지 단위로 변환합니다. mmap의 실제 구현인
ksys_mmap_pgoff()는 페이지 단위 오프셋을 받으므로, 시스템 콜 경계에서 변환을 수행합니다.
시스템 콜 진입 경로 상세
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)
코드 설명
- 1행
SYM_CODE_START(entry_SYSCALL_64)— 어셈블리 심볼 선언 매크로.MSR_LSTAR에 등록된 주소로,SYSCALL명령어 실행 시 CPU가 직접 점프합니다. - 3행
swapgs— IA32_KERNEL_GS_BASE MSR과 현재 GS base를 교환합니다. 커널 per-CPU 데이터(task_struct 포인터 등)에 접근하기 위해 필수입니다. - 6행사용자 RSP를
cpu_tss_rw.sp2필드에 임시 보관합니다. 아직 커널 스택이 아니므로 메모리 쓰기는 불가하고 per-CPU 슬롯을 활용합니다. - 9행
pcpu_hot.X86_top_of_stack에서 커널 스택 최상위 주소를 읽어 RSP를 교체합니다. 이후 모든 push 연산은 커널 스택에 기록됩니다. - 12~17행
__USER_DS·__USER_CS·%r11·%rcx·%rax를 순서대로 push하여 커널 스택에pt_regs구조체를 형성합니다.%rax에 시스템 콜 번호가,%rcx에 복귀 RIP가,%r11에 사용자 RFLAGS가 들어 있습니다. - 20행
PUSH_AND_CLEAR_REGS매크로로 나머지 범용 레지스터를 저장하고,rax에-ENOSYS를 설정해 시스템 콜 번호를 덮어씁니다(핸들러가 교체 전까지 유효한 초기값). - 23~24행
rdi←rsp(pt_regs 포인터),rsi← 시스템 콜 번호로 설정한 뒤do_syscall_64()를 호출합니다. System V AMD64 ABI에 따라 첫 두 인자를 rdi·rsi로 전달합니다. - 28~29행반환 경로에서 다시
swapgs로 GS base를 사용자 값으로 복원한 뒤sysretq로 RCX(사용자 RIP)·R11(RFLAGS)을 복원하고 CPL을 3으로 전환합니다.
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;
};
코드 설명
- r15~r12, bp, bxcallee-saved 범용 레지스터입니다. 시스템 콜 핸들러가 이 레지스터를 수정하더라도 복귀 전에 복원되어야 합니다.
- r11, r10, r9, r8caller-saved 레지스터입니다.
r11은SYSCALL명령어가 사용자 RFLAGS를 보관하는 특별한 용도로 사용됩니다. - ax, cx, dx, si, di시스템 콜 인자 전달용 레지스터입니다. AMD64 ABI에서 인자 순서는 rdi→rsi→rdx→r10→r8→r9이며,
cx는 반환 주소(RIP)를 보관합니다. - orig_ax원래 시스템 콜 번호를 보존합니다.
ax는 핸들러 반환값으로 덮어씌워지므로 시스템 콜 번호가 필요한 재시작(restart) 처리나 ptrace/seccomp 검사를 위해 별도로 저장됩니다. - ip
SYSCALL명령어가RCX ← 다음 명령어 RIP를 저장한 값입니다.SYSRET시 이 값으로 사용자 공간으로 복귀합니다. - flags
SYSCALL명령어가R11 ← RFLAGS를 저장한 값입니다.SYSRET은 이 값을 RFLAGS로 복원합니다. - sp, 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;
}
코드 설명
- 1행 — do_syscall_64 선언
__visible은 LTO(Link-Time Optimization) 환경에서 심볼을 제거하지 않도록 지시하고,noinstr은 이 함수 내부에서 ftrace·kprobes 계측(instrumentation)을 비활성화합니다. 보안 검사 전에 추적 훅이 실행되지 않아야 하기 때문입니다. - 3행 — add_random_kstack_offset()커널 스택의 시작 오프셋을 0~255바이트 범위에서 무작위로 조정합니다. 호출마다 스택 레이아웃이 달라지므로 스택 주소 기반 공격(스택 스프레이, info-leak)을 어렵게 만드는 커널 자기 보호 기법입니다.
- 4행 — syscall_enter_from_user_mode()사용자 모드에서 커널 모드로 진입할 때 필요한 상태 전환을 처리합니다. 내부에서 ptrace, seccomp BPF 필터, audit 검사를 순서대로 실행하며, 반환값은 실제 사용할 시스템 콜 번호(ptrace가 변경할 수 있음)입니다.
- 6행 — instrumentation_begin()이 지점부터 kprobes, ftrace, KASAN 등의 동적 계측을 허용합니다.
noinstr구간을 벗어나는 경계를 명시하는 마커입니다. - 8~9행 — do_syscall_x64 / do_syscall_x32
do_syscall_x64()가false를 반환하면(유효 번호 범위 초과) x32 ABI 경로를 시도합니다. x32는 64비트 모드에서 32비트 포인터를 쓰는 ABI로, 번호에__X32_SYSCALL_BIT(0x40000000)가 설정되어 있습니다. - do_syscall_x64 — array_index_nospec()Spectre v1 완화를 위한 인덱스 마스킹 함수입니다. 경계 검사 후에도 투기적 실행이 범위를 벗어날 수 있으므로,
nr가NR_syscalls를 넘지 못하도록 AND 마스크를 적용합니다. - do_syscall_x64 — sys_call_table[nr](regs)함수 포인터 테이블을 통한 핸들러 호출입니다. 반환값은
regs->ax에 저장되어 사용자 공간에 시스템 콜 반환값으로 전달됩니다. - 11행 — syscall_exit_to_user_mode()커널 → 사용자 복귀 전 체크리스트를 수행합니다. 시그널 전달, TIF_NOTIFY_RESUME, 스케줄링, seccomp exit 검사가 포함됩니다.
array_index_nospec()는 Spectre v1 방어를 위한 투기적 실행(Speculative Execution) 차단 함수입니다.
진입 경로 상세 다이어그램
콜 체인 소스 분석: entry_SYSCALL_64 → do_syscall_64() → sys_call_table[nr]
x86_64 시스템 콜의 세 핵심 함수가 어떻게 연결되는지 소스 수준에서 추적합니다. 하드웨어가 MSR_LSTAR를 통해 어셈블리 진입점으로 점프하고, C 핸들러가 시스템 콜 번호로 함수 포인터 테이블을 인덱싱하는 전체 경로를 보여줍니다.
사용자 메모리 접근 보안
커널 코드에서 시스템 콜 인자로 전달받은 사용자 포인터에 접근할 때는 반드시 전용 함수를 사용해야 합니다. 잘못된 포인터, 커널 공간 주소 위장, 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를 반환해야 합니다. 이를 무시하면 데이터 손상이나 정보 누출이 발생할 수 있습니다.
copy_from_user() 내부 콜 체인
앞서 본 copy_from_user()는 얇은 래퍼(Wrapper)에 불과합니다. 실제 실행 경로는 여러 보안 계층을 거칩니다. 아래는 x86_64 기준 전체 콜 체인입니다.
copy_from_user(to, from, n)
├─ access_ok(from, n) /* 사용자 주소 범위 검증 */
├─ __check_object_size(to, n, false) /* HARDENED_USERCOPY: 커널 객체 경계 검증 */
├─ instrument_copy_from_user() /* KASAN/KCSAN 메모리 계측 */
└─ raw_copy_from_user(to, from, n)
├─ stac() /* SMAP 일시 해제 (Set AC flag) */
├─ barrier_nospec() /* LFENCE: Spectre v1 방어 */
├─ rep movsb /* 실제 바이트 복사 (어셈블리) */
│ └─ [페이지 폴트] → extable fixup → 미복사 바이트 수 반환
└─ clac() /* SMAP 복원 (Clear AC flag) */
├─ (ret > 0) → memset(to + n - ret, 0, ret) /* 잔여 바이트 제로화 */
└─ return ret /* 0=성공, >0=미복사 바이트 수 */
실제 구현은 lib/usercopy.c의 _copy_from_user()에 있습니다. 인라인 copy_from_user()는 컴파일 타임 상수 크기일 때 최적화 경로를 선택하고, 그 외에는 이 함수를 호출합니다.
/* lib/usercopy.c — _copy_from_user() 실제 구현 */
unsigned long _copy_from_user(void *to,
const void __user *from, unsigned long n)
{
unsigned long res = n;
might_fault(); /* 프로세스 컨텍스트 확인 (디버그) */
if (!should_fail_usercopy() &&
likely(access_ok(from, n))) {
__check_object_size(to, n, false);
res = raw_copy_from_user(to, from, n);
}
if (unlikely(res))
memset(to + (n - res), 0, res); /* 정보 누출 방지 */
return res;
}
EXPORT_SYMBOL(_copy_from_user);
코드 설명
- might_fault()디버그 빌드에서 현재 컨텍스트가 프로세스 컨텍스트인지 확인합니다. 인터럽트나 atomic 컨텍스트에서
copy_from_user()를 호출하면 경고를 발생시킵니다. 사용자 메모리 접근은 페이지 폴트를 유발할 수 있어 슬립(sleep)이 가능한 컨텍스트에서만 안전합니다. - should_fail_usercopy()fault injection 프레임워크와 연동하여, 테스트 목적으로 의도적으로 복사를 실패시킬 수 있습니다.
CONFIG_FAULT_INJECTION_USERCOPY가 활성화된 경우에만 동작합니다. - __check_object_size(to, n, false)
HARDENED_USERCOPY검증입니다. 커널 포인터to가 가리키는 객체의 SLAB 경계, 스택 범위, 텍스트/rodata 영역을 검사합니다. 상세 내용은 커널 하드닝 — HARDENED_USERCOPY 참조. - memset(to + (n - res), 0, res)복사가 부분 실패하면, 복사되지 않은 나머지 영역을 0으로 채웁니다. 이는 커널 스택/힙에 남아있던 이전 데이터가 사용자 공간으로 누출되는 것을 방지하는 핵심 보안 조치입니다.
예외 테이블(Exception Table) 메커니즘
copy_from_user()가 단순한 memcpy()와 근본적으로 다른 이유는 예외 테이블(Exception Table) 메커니즘입니다. access_ok()를 통과한 사용자 주소라도 실제 접근 시 페이지 폴트(Page Fault)가 발생할 수 있습니다 — 페이지가 스왑 아웃되었거나, 매핑이 해제되었거나, 권한이 변경된 경우입니다. 이때 커널은 패닉(panic) 대신 우아하게 복구합니다.
컴파일 타임: 사용자 메모리에 접근하는 어셈블리 명령어마다 _ASM_EXTABLE 매크로가 __ex_table 섹션에 fixup 엔트리를 생성합니다.
/* arch/x86/include/asm/asm.h — 예외 테이블 엔트리 생성 매크로 */
#define _ASM_EXTABLE_HANDLE(from, to, handler) \
.pushsection "__ex_table", "a" ; \
.balign 4 ; \
.long (from) - . ; /* 폴트 발생 가능 명령어 주소 (상대) */ \
.long (to) - . ; /* fixup 코드 주소 (상대) */ \
.long (handler) - . ; /* 핸들러 함수 (상대) */ \
.popsection
/* 사용 예시: raw_copy_from_user 내부 어셈블리 */
1: rep movsb /* 사용자 메모리에서 커널로 복사 */
2: ... /* 정상 종료 */
.section .fixup, "ax"
3: mov %ecx, %eax /* 미복사 바이트 수 = ECX(남은 카운트) */
jmp 2b
.previous
_ASM_EXTABLE(1b, 3b) /* 1번 명령에서 폴트 → 3번(fixup)으로 점프 */
런타임: 페이지 폴트가 커널 모드에서 발생하면, do_page_fault()는 예외 테이블을 검색하여 fixup 코드로 분기합니다.
/* arch/x86/mm/fault.c — 커널 모드 페이지 폴트 처리 (간략화) */
static noinline void
do_kern_addr_fault(struct pt_regs *regs,
unsigned long error_code,
unsigned long address)
{
/* 커널 모드에서 폴트 → 예외 테이블 검색 */
if (fixup_exception(regs, X86_TRAP_PF,
error_code, address))
return; /* fixup 성공: IP를 fixup 코드로 변경 */
/* 예외 테이블에 없으면 → 커널 Oops/패닉 */
die("Oops", regs, error_code);
}
/* kernel/extable.c — 예외 테이블 검색 및 fixup */
bool fixup_exception(struct pt_regs *regs, int trapnr,
unsigned long error_code,
unsigned long fault_addr)
{
const struct exception_table_entry *e;
/* 폴트 발생 주소(IP)로 __ex_table 이진 검색 */
e = search_exception_tables(regs->ip);
if (!e)
return false; /* fixup 없음 → Oops */
/* IP를 fixup 주소로 변경, AX에 미복사 바이트 수 설정 */
return ex_handler_uaccess(e, regs, trapnr,
error_code, fault_addr);
}
코드 설명
- .pushsection "__ex_table", "a"컴파일러에게 이후 데이터를
__ex_table섹션에 배치하도록 지시합니다."a"는 allocatable 속성으로, 이 섹션이 메모리에 로드됩니다. 링커가 모든 오브젝트 파일의__ex_table엔트리를 하나로 합칩니다. - .long (from) - .폴트가 발생할 수 있는 명령어의 상대 주소입니다. 절대 주소 대신 상대 주소를 사용하여 KASLR(Kernel Address Space Layout Randomization) 환경에서도 테이블이 정상 동작합니다.
- search_exception_tables(regs->ip)폴트가 발생한 명령어 포인터(IP)를 키로
__ex_table을 이진 검색합니다. 테이블은 링크 타임에 주소 순으로 정렬됩니다. - ex_handler_uaccess()uaccess 전용 fixup 핸들러입니다.
regs->ip를 fixup 코드 주소로,regs->ax를 미복사 바이트 수로 설정합니다. 이후do_page_fault()가 반환되면 fixup 코드부터 실행이 재개됩니다.
memcpy()를 사용하여 사용자 주소에 접근하면, 페이지 폴트 시 예외 테이블 엔트리가 없으므로 커널 Oops가 발생합니다. copy_from_user()는 모든 사용자 메모리 접근 명령어에 fixup 엔트리를 미리 등록하므로, 어떤 바이트 위치에서 폴트가 발생하더라도 안전하게 복구할 수 있습니다.
잔여 바이트 제로화(Zero-Remaining)
copy_from_user()가 부분적으로 실패하면, 커널 대상 버퍼에는 일부만 복사되고 나머지는 이전 스택/힙 데이터가 남아있습니다. 이 상태에서 해당 버퍼를 copy_to_user()로 사용자 공간에 전달하면, 커널 메모리가 누출됩니다. 이를 방지하기 위해 copy_from_user()는 미복사 영역을 항상 0으로 채웁니다.
/* lib/usercopy.c — _copy_from_user() 후반부 */
if (unlikely(res))
memset(to + (n - res), 0, res);
/*
* 예시: n=100, res=30 (70바이트만 성공, 30바이트 실패)
* to[0..69] = 사용자 데이터 (정상 복사)
* to[70..99] = 0x00 (제로화 — 이전 커널 데이터 덮어쓰기)
*/
| 함수 | 실패 시 잔여 바이트 처리 | 이유 |
|---|---|---|
copy_from_user() |
memset(0) 제로화 |
커널 버퍼에 이전 데이터 누출 방지 |
copy_to_user() |
제로화 없음 | 사용자 버퍼는 이미 사용자 소유 — 커널 정보 누출 위험 없음 |
get_user() |
변수를 0으로 설정 | 단일 값에도 동일 원칙 적용 |
strncpy_from_user() |
복사된 부분까지만 유효 | 반환값이 길이이므로 호출자가 범위를 알 수 있음 |
copy_from_user()만 이 보호를 수행합니다.
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 ...
unsafe_get_user / unsafe_put_user 패턴
일반적인 get_user()/put_user()는 매 호출마다 STAC/CLAC(또는 PAN enable/disable)를 실행합니다. 이들은 직렬화 명령어(serializing instruction)이므로 파이프라인을 비우고 성능에 영향을 줍니다. 여러 사용자 값을 연속으로 읽고 쓸 때는 user_access_begin()/user_access_end() 블록 안에서 unsafe_* 변형을 사용하여 STAC/CLAC 한 쌍만 실행합니다.
/* arch/x86/kernel/signal.c — 시그널 프레임 설정 예시 */
static int setup_rt_frame(struct ksignal *ksig,
struct pt_regs *regs)
{
struct rt_sigframe __user *frame;
/* ... frame 주소 계산 ... */
/* STAC 한 번만 실행 + access_ok 검증 */
if (!user_access_begin(frame, sizeof(*frame)))
return -EFAULT;
/* 블록 안에서 반복 접근 — 개별 STAC/CLAC 없음 */
unsafe_put_user(restorer, &frame->pretcode, Efault);
unsafe_put_user(sig, &frame->sig, Efault);
unsafe_put_user(pinfo, &frame->pinfo, Efault);
unsafe_put_user(puc, &frame->puc, Efault);
user_access_end(); /* CLAC 한 번만 실행 */
return 0;
Efault:
user_access_end();
return -EFAULT;
}
코드 설명
- user_access_begin(frame, sizeof(*frame))내부적으로
access_ok()로 주소 범위를 검증한 후__uaccess_begin_nospec()(STAC + LFENCE)를 실행합니다. 실패 시 false를 반환하여 접근을 차단합니다. - unsafe_put_user(val, ptr, label)access_ok()와 STAC/CLAC 없이 직접 사용자 메모리에 씁니다. 폴트 발생 시 extable fixup이
label(여기서는 Efault)로 점프합니다. 반드시user_access_begin/end블록 안에서만 사용해야 합니다. - Efault: user_access_end();에러 경로에서도 반드시
user_access_end()(CLAC)를 호출하여 SMAP 보호를 복원합니다. goto 레이블을 사용하는 이유는 매번 if-check를 피하면서도 예외 처리를 보장하기 위함입니다.
access_ok() 검증과 SMAP/PAN 보호가 모두 우회됩니다. 일반적인 단일 접근에는 get_user()/put_user()를 사용하고, 3개 이상 연속 접근 시에만 unsafe_* 패턴을 고려하세요.
strncpy_from_user / strnlen_user
strncpy_from_user()와 strnlen_user()는 NUL 종단 문자열 전용 사용자 메모리 접근 함수입니다. 파일명 복사(getname_flags()), 모듈명 검증 등에서 사용됩니다. copy_from_user()와 달리 NUL 문자를 만나면 복사를 중단합니다.
/* lib/strncpy_from_user.c — 사용자 문자열을 커널로 복사 */
long strncpy_from_user(char *dst,
const char __user *src, long count)
/*
* 반환값:
* >= 0 : 복사된 문자열 길이 (NUL 제외)
* -EFAULT : 접근 실패
* NUL 종단 문자까지 복사하되 count 바이트를 초과하지 않음
* count에 도달해도 NUL을 만나지 못하면 count 반환 (NUL 종단 없음!)
*/
/* 실제 사용 예시: fs/namei.c — 파일명 복사 */
struct filename * getname_flags(
const char __user *filename, int flags)
{
struct filename *result;
char *kname;
int len;
result = audit_reusename(filename);
if (result)
return result;
result = __getname(); /* SLAB에서 filename 구조체 할당 */
kname = (char *)result->iname;
len = strncpy_from_user(kname, filename, EMBEDDED_NAME_MAX);
if (unlikely(len < 0)) {
__putname(result);
return ERR_PTR(len); /* -EFAULT */
}
/* len == EMBEDDED_NAME_MAX이면 별도 페이지 할당하여 긴 경로 처리 */
}
| 함수 | 용도 | 반환값 | NUL 종단 |
|---|---|---|---|
copy_from_user() |
고정 크기 바이너리 데이터 | 미복사 바이트 수 (0=성공) | 해당 없음 |
strncpy_from_user() |
NUL 종단 문자열 복사 | 문자열 길이 또는 -EFAULT | 자동 추가 (count 미달 시) |
strnlen_user() |
문자열 길이 측정 (복사 없음) | 길이+1 (NUL 포함) 또는 0(실패) | 측정만 |
get_user() |
단일 스칼라 값 (1/2/4/8바이트) | 0=성공, -EFAULT=실패 | 해당 없음 |
copy_struct_from_user() — 확장 가능한 구조체 복사
시스템 콜이 사용자 구조체를 받을 때, 커널과 사용자 프로그램의 구조체 크기가 다를 수 있습니다. 구조체에 새 필드가 추가된 새 커널에서 이전 사용자 프로그램이 작은 구조체를 전달하거나, 반대로 새 사용자 프로그램이 이전 커널에 큰 구조체를 전달할 수 있습니다. copy_struct_from_user()는 양방향 호환성을 보장합니다.
/* lib/strnlen_user.c / include/linux/uaccess.h */
int copy_struct_from_user(void *dst, size_t ksize,
const void __user *src,
size_t usize)
{
size_t size = min(ksize, usize);
/* 1단계: 커널 버퍼 전체를 0으로 초기화
* → 사용자가 안 보낸 필드는 자동으로 0 (기본값) */
memset(dst, 0, ksize);
if (usize > ksize) {
/* 2단계: 사용자 구조체가 더 큼 → 초과 부분이 0인지 확인
* 미지의 필드에 값이 있으면 커널이 이해 못하므로 거부 */
if (check_zeroed_user(src + ksize, usize - ksize))
return -E2BIG;
}
/* 3단계: 공통 부분만 복사 */
if (copy_from_user(dst, src, size))
return -EFAULT;
return 0;
}
/* kernel/fork.c — clone3 시스템 콜 실사용 예시 */
SYSCALL_DEFINE2(clone3,
struct clone_args __user *, uargs,
size_t, size)
{
struct kernel_clone_args kargs;
struct clone_args args;
/* size = 사용자가 전달한 구조체 크기
* sizeof(args) = 커널이 기대하는 구조체 크기 */
err = copy_struct_from_user(&args, sizeof(args),
uargs, size);
/*
* 이전 userspace (size < sizeof) → 부족분 0으로 채움
* 이후 userspace (size > sizeof) → 초과분 0 확인 후 복사
* 동일 크기 → 그대로 복사
*/
}
clone3, openat2, sched_setattr, mount_setattr, futex_waitv 등. 새로운 시스템 콜에서 확장 가능한 구조체를 받을 때는 이 패턴을 사용하는 것이 권장됩니다. 핵심 규칙은 "새 필드는 항상 0이 기본값"이어야 한다는 것입니다.
__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"
copy_from_user() 전체 실행 흐름 다이어그램
Spectre v1 완화와 uaccess
Spectre v1(Bounds Check Bypass)은 투기적 실행(Speculative Execution)을 이용하여 access_ok() 검증을 우회하고 커널 메모리를 읽을 수 있는 취약점입니다. 공격자가 조건 분기를 학습시켜, CPU가 검증 실패 경로를 투기적으로 실행하도록 유도합니다. 이때 사용자 메모리 접근 코드가 임의의 커널 주소를 읽고, 캐시 사이드 채널(side channel)을 통해 데이터를 유출할 수 있습니다.
/* arch/x86/include/asm/uaccess.h — Spectre v1 방어 포함 */
static inline void __uaccess_begin_nospec(void)
{
stac(); /* SMAP 해제 (Set AC flag) */
barrier_nospec(); /* LFENCE: 투기적 실행 차단 */
}
/* arch/x86/include/asm/barrier.h */
#define barrier_nospec() asm volatile("lfence" ::: "memory")
/*
* LFENCE는 이전의 모든 로드가 완료될 때까지
* 이후 명령어의 투기적 실행을 차단합니다.
* STAC 직후 삽입하여, access_ok() 검증이 완료되기 전에
* 사용자 메모리 접근이 투기적으로 실행되는 것을 방지합니다.
*/
시스템 콜 테이블 접근에도 동일한 원칙이 적용됩니다. 시스템 콜 번호가 범위를 초과하는 경우, 투기적 실행이 테이블 경계 밖의 함수 포인터를 읽는 것을 array_index_nospec()으로 방지합니다.
/* include/linux/nospec.h — 투기적 OOB 접근 방지 */
#define array_index_nospec(index, size) \
({ \
typeof(index) _i = (index); \
typeof(size) _s = (size); \
unsigned long _mask = \
array_index_mask_nospec(_i, _s); \
(typeof(_i)) (_i & _mask); \
})
/* _mask = (index < size) ? ~0UL : 0
* index >= size이면 _mask=0 → 결과=0 (투기적 OOB 방지) */
/* arch/x86/entry/common.c — 시스템 콜 디스패치 */
static noinline bool __do_fast_syscall_32(
struct pt_regs *regs)
{
unsigned int nr = regs->orig_ax;
/* 투기적 실행에서도 nr이 범위를 벗어나면 0으로 클램프 */
nr = array_index_nospec(nr, IA32_NR_syscalls);
regs->ax = ia32_sys_call_table[nr](regs);
}
copy_from_user() 호출당 수 나노초의 오버헤드가 추가됩니다. 그러나 Spectre v1 공격을 근본적으로 차단하므로, 보안 이점이 성능 비용을 크게 상회합니다. user_access_begin()/user_access_end() 패턴을 사용하면 LFENCE 횟수를 줄여 오버헤드를 최소화할 수 있습니다.
시스템 콜 재시작 메커니즘
사용자 프로세스가 시스템 콜(예: read(), nanosleep()) 실행 중 시그널을 수신하면, 시스템 콜이 중단됩니다. 이때 커널은 시스템 콜을 자동으로 재시작할지, 아니면 -EINTR로 반환할지 결정해야 합니다. 이 결정은 커널 반환 에러 코드와 시그널 핸들러의 SA_RESTART 플래그에 의해 이루어집니다.
4가지 재시작 에러 코드
커널 시스템 콜 핸들러가 시그널에 의해 중단될 때 반환하는 내부 에러 코드는 4가지입니다. 이 코드들은 사용자 공간에 직접 노출되지 않으며, do_signal()에서 재시작 판단에 사용됩니다.
| 에러 코드 | 값 | SA_RESTART 설정 시 | SA_RESTART 미설정 시 | 사용 예 |
|---|---|---|---|---|
-ERESTARTSYS |
512 | 자동 재시작 | -EINTR 반환 |
read(), write(), wait4() |
-ERESTARTNOHAND |
514 | 자동 재시작 | 자동 재시작 | pause(), sigsuspend() |
-ERESTARTNOINTR |
513 | 항상 재시작 | 항상 재시작 | 시그널 자체 처리 코드 |
-ERESTART_RESTARTBLOCK |
516 | restart_block 사용 |
-EINTR 반환 |
nanosleep(), futex() |
-ERESTARTSYS(512) 등의 값은 일반적인 errno 범위(1~4095)를 넘지 않지만, MAX_ERRNO(4095) 이내이므로 사용자 공간에 절대 노출되어서는 안 됩니다. 커널은 do_signal()에서 이 코드를 가로채어 재시작 또는 -EINTR(-4)로 변환합니다.
do_signal()의 재시작 판단 로직
시그널 처리 후 시스템 콜 반환값을 검사하여 재시작 여부를 결정하는 핵심 코드입니다.
/* arch/x86/kernel/signal.c (Linux 6.x, 단순화) */
static void handle_signal(struct ksignal *ksig,
struct pt_regs *regs)
{
int ret = regs->ax;
switch (ret) {
case -ERESTARTSYS:
if (!(ksig->ka.sa.sa_flags & SA_RESTART)) {
regs->ax = -EINTR; /* SA_RESTART 없으면 EINTR */
break;
}
/* fallthrough: SA_RESTART 있으면 재시작 */
case -ERESTARTNOINTR:
regs->ax = regs->orig_ax; /* 원래 시스템 콜 번호 복원 */
regs->ip -= 2; /* syscall 명령어 크기(2바이트) 되감기 */
break;
case -ERESTARTNOHAND:
regs->ax = -EINTR; /* 핸들러가 있으면 EINTR */
break;
case -ERESTART_RESTARTBLOCK:
regs->ax = -EINTR; /* 핸들러 처리 후 EINTR */
break;
}
}
regs->ip -= 2는 x86_64 syscall 명령어의 크기(2바이트)만큼 명령어 포인터를 되감습니다. 이렇게 하면 커널에서 사용자 공간으로 복귀할 때 syscall 명령어가 다시 실행되어 시스템 콜이 재시작됩니다. ARM64에서는 SVC #0이 4바이트이므로 regs->pc -= 4입니다.
restart_block과 나머지 시간 계산
-ERESTART_RESTARTBLOCK은 단순한 재시작이 불가능한 경우에 사용됩니다. 예를 들어 nanosleep(2초) 호출 중 1초 후에 시그널이 도착하면, 남은 1초만 잠들어야 합니다. 이때 restart_block 구조체에 재시작 함수와 남은 시간을 저장합니다.
/* include/linux/restart_block.h */
struct restart_block {
unsigned long arch_data;
long (*fn)(struct restart_block *);
union {
struct { /* nanosleep용 */
struct timespec64 expires;
clockid_t clockid;
u32 type;
} nanosleep;
struct { /* futex용 */
u32 __user *uaddr;
u32 val;
u32 flags;
u64 time;
} futex;
struct { /* poll용 */
struct pollfd __user *ufds;
int nfds;
int has_timeout;
unsigned long tv_sec;
unsigned long tv_nsec;
} poll;
};
};
/* kernel/time/hrtimer.c — nanosleep 재시작 예제 */
static int __sched do_nanosleep(struct hrtimer_sleeper *t,
enum hrtimer_mode mode)
{
struct restart_block *restart;
/* ... hrtimer 대기 ... */
if (signal_pending(current)) {
/* 시그널 도착: 남은 시간으로 restart_block 설정 */
restart = ¤t->restart_block;
restart->fn = hrtimer_nanosleep_restart;
restart->nanosleep.clockid = t->timer.base->clockid;
restart->nanosleep.expires = hrtimer_get_softexpires_tv64(&t->timer);
return -ERESTART_RESTARTBLOCK;
}
return 0;
}
SA_RESTART 플래그와 sigaction()
사용자 공간에서 시그널 핸들러를 등록할 때 SA_RESTART 플래그를 설정하면, -ERESTARTSYS로 중단된 시스템 콜이 자동으로 재시작됩니다.
/* SA_RESTART 사용 예제 */
struct sigaction sa;
sa.sa_handler = my_handler;
sa.sa_flags = SA_RESTART; /* 이 플래그가 핵심! */
sigemptyset(&sa.sa_mask);
sigaction(SIGUSR1, &sa, NULL);
/* SA_RESTART 있으면: read() 중 SIGUSR1 → 핸들러 실행 → read() 자동 재시작 */
/* SA_RESTART 없으면: read() 중 SIGUSR1 → 핸들러 실행 → read()=-1, errno=EINTR */
/* 주의: signal() 함수는 SA_RESTART를 기본 설정합니다 (glibc) */
signal(SIGUSR1, my_handler); /* glibc: SA_RESTART 포함 */
/* BSD 호환이 아닌 경우 (예: musl libc) signal()은 SA_RESTART 미설정 */
SA_RESTART에 의존하지 말고, 시스템 콜이 -EINTR을 반환할 수 있다고 항상 가정하세요. 특히 select(), poll(), epoll_wait()는 SA_RESTART가 설정되어도 재시작되지 않습니다. 안전한 패턴: while ((ret = read(fd, buf, n)) == -1 && errno == EINTR) continue;
새로운 시스템 콜 추가
단계별 가이드
리눅스 커널에 새로운 시스템 콜을 추가하는 과정을 단계별로 설명합니다. 아래는 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).
ABI 안정성 정책
리눅스 커널의 가장 중요한 원칙 중 하나는 "사용자 공간을 절대 깨뜨리지 않는다"(Linus Torvalds: "We do NOT break userspace")입니다. 한 번 할당된 시스템 콜 번호는 영원히 유지되며, 사용되지 않게 된 시스템 콜도 번호를 반환하지 않고 sys_ni_syscall 스텁(Stub)으로 교체합니다.
/* kernel/sys_ni.c — 미구현 시스템 콜 스텁 */
COND_SYSCALL(io_setup);
COND_SYSCALL(io_destroy);
/*
* sys_ni_syscall — "not implemented" 스텁
* 제거된 시스템 콜 번호에 매핑되어 -ENOSYS 반환
* 예: sys_set_thread_area (번호 205) — x86_64에서 미구현
*/
SYSCALL_DEFINE0(ni_syscall)
{
return -ENOSYS;
}
시스템 콜 진화 사례
기존 시스템 콜의 한계를 극복하기 위해 새로운 시스템 콜이 추가되는 패턴은 리눅스 커널 역사에서 반복됩니다. 기존 시스템 콜은 제거되지 않고 영구히 유지됩니다.
| 원래 시스템 콜 | 개선된 시스템 콜 | 변경 이유 | 커널 버전 |
|---|---|---|---|
clone | clone3 | 64비트 flags 확장, 구조체 기반 인자로 미래 호환성 확보 | 5.3 |
open | openat | 상대 경로의 TOCTOU 취약점 방지 (dirfd 기반) | 2.6.16 |
openat | openat2 | RESOLVE_* 플래그로 심볼릭 링크 추적 정책 제어 | 5.6 |
epoll_create | epoll_create1 | EPOLL_CLOEXEC 지원 (close-on-exec 원자적 설정) | 2.6.27 |
stat/lstat/fstat | statx | 생성 시각, 마운트 ID 등 확장 정보 + 선택적 필드 쿼리 | 4.11 |
select | pselect6 | 나노초 타임아웃 + 시그널 마스크 원자적 설정 | 2.6.16 |
pipe | pipe2 | O_CLOEXEC, O_NONBLOCK 원자적 설정 | 2.6.27 |
dup2 | dup3 | O_CLOEXEC 지원 | 2.6.27 |
signalfd | signalfd4 | SFD_CLOEXEC, SFD_NONBLOCK 플래그 | 2.6.27 |
renameat | renameat2 | RENAME_NOREPLACE, RENAME_EXCHANGE 원자적 교환 | 3.15 |
*2/*4 시스템 콜은 O_CLOEXEC 플래그를 원자적으로 설정하기 위함입니다. fork()+exec() 사이 경쟁 조건에서 파일 디스크립터가 누출되는 보안 문제를 해결합니다. 이 교훈 이후 새로운 시스템 콜에는 항상 플래그 인자를 포함하도록 권장됩니다.
새 시스템 콜 추가의 커뮤니티 프로세스
리눅스 커널에 새 시스템 콜을 추가하는 것은 커널 변경 중 가장 신중한 검토가 필요한 작업입니다. 한 번 추가되면 영원히 유지해야 하기 때문입니다.
- 사전 논의 — LKML(Linux Kernel Mailing List)에 RFC 패치와 함께 제안서를 게시합니다. 기존 시스템 콜로 해결할 수 없는 이유, ioctl/netlink/sysfs 대안이 부적합한 이유를 명확히 설명해야 합니다.
- 인터페이스 설계 검토 — 인자 개수(최대 6개), 구조체 사용 시 확장 가능한
flags/size필드 포함, 32비트/64비트 호환성, compat 래퍼 필요 여부를 검토합니다. - 패치 세트 작성 — 다음을 포함합니다:
- 시스템 콜 테이블 등록 (모든 아키텍처: x86, ARM64, RISC-V 등)
SYSCALL_DEFINE핸들러 구현- kselftest 테스트 (
tools/testing/selftests/) - man-pages 업데이트 패치 (별도 저장소)
- UAPI 헤더 (
include/uapi/linux/)
- 리뷰 및 머지 — 서브시스템 메인테이너 리뷰 → linux-next 트리 통합 → 다음 릴리스 머지 윈도우에서 메인라인 통합.
- glibc 래퍼 추가 — 커널 머지 후 glibc 프로젝트에 래퍼 함수를 추가하는 별도 패치가 필요합니다. glibc 래퍼가 추가되기 전에는
syscall(__NR_xxx, ...)으로만 호출 가능합니다.
커널 내부 시스템 콜 사용
커널 스레드(Kernel Thread)나 초기 부팅 코드에서도 시스템 콜의 기능이 필요한 경우가 있습니다. 그러나 커널 내부에서 시스템 콜을 직접 호출하는 것은 권장되지 않으며, 대신 ksys_* 접두사의 내부 함수를 사용합니다.
ksys_* 함수 패턴
ksys_* 함수는 시스템 콜 핸들러의 핵심 로직을 추출한 것으로, pt_regs 구조체 없이 직접 인자를 받습니다.
/* fs/read_write.c */
/* 시스템 콜 핸들러: pt_regs에서 인자를 추출 */
SYSCALL_DEFINE3(read, unsigned int, fd,
char __user *, buf, size_t, count)
{
return ksys_read(fd, buf, count);
}
/* ksys_read: 커널 내부에서도 호출 가능 */
ssize_t ksys_read(unsigned int fd,
char __user *buf, size_t count)
{
struct fd f = fdget_pos(fd);
ssize_t ret = -EBADF;
if (f.file) {
loff_t pos, *ppos = file_ppos(f.file);
if (ppos) {
pos = *ppos;
ppos = &pos;
}
ret = vfs_read(f.file, buf, count, ppos);
if (ret >= 0 && ppos)
f.file->f_pos = pos;
fdput_pos(f);
}
return ret;
}
kernel_execve()와 kernel_clone()
커널이 첫 번째 사용자 프로세스(/sbin/init)를 실행하거나, 커널 스레드를 생성할 때 사용하는 내부 함수입니다.
/* init/main.c — 커널 초기화에서 init 프로세스 실행 */
static int __ref kernel_init(void *unused)
{
/* ... 초기화 작업 ... */
/* /sbin/init 실행: 커널에서 사용자 프로그램으로 전환 */
if (!try_to_run_init_process("/sbin/init") ||
!try_to_run_init_process("/etc/init") ||
!try_to_run_init_process("/bin/init") ||
!try_to_run_init_process("/bin/sh"))
return 0;
panic("No working init found.");
}
static int try_to_run_init_process(const char *init_filename)
{
/* kernel_execve: 커널 컨텍스트에서 ELF 바이너리 실행 */
return kernel_execve(init_filename, argv_init, envp_init);
}
/* kernel/fork.c — 커널 스레드 생성 */
pid_t kernel_clone(struct kernel_clone_args *args)
{
/* clone 시스템 콜과 동일한 로직이지만
* 사용자 공간 검증 없이 커널 내부에서 직접 실행 */
struct task_struct *p;
p = copy_process(NULL, 0, args);
/* ... */
}
초기 부팅 코드의 시스템 콜 사용
커널 초기화 과정(init/main.c의 kernel_init())에서 파일 시스템 마운트, 장치 노드 생성 등을 위해 시스템 콜 기능을 사용합니다.
/* init/do_mounts.c — 초기 루트 파일 시스템 마운트 */
void __init mount_root(void)
{
/* ksys_mount: mount() 시스템 콜의 커널 내부 버전 */
init_mount(".", "/", NULL, MS_MOVE, NULL);
init_chroot(".");
}
/* init/initramfs.c — initramfs 압축 해제 */
static int __init do_copy(void)
{
/* init_mkdir, init_mknod, init_symlink 등
* 시스템 콜 대응 함수로 초기 디렉터리 구조 생성 */
init_mkdir("/dev", 0755);
init_mknod("/dev/console", S_IFCHR|0600,
new_encode_dev(MKDEV(5, 1)));
}
set_fs() 제거와 주소 공간 정책 변경
Linux 5.18 이전에는 set_fs(KERNEL_DS)를 사용하여 커널 내부에서 copy_from_user() 등의 함수를 커널 포인터에 대해 호출할 수 있었습니다. 이는 보안 위험이 크므로 Linux 5.18에서 완전히 제거되었습니다.
/* Linux 5.18 이전 (제거됨) — 위험한 패턴 */
mm_segment_t old_fs = get_fs();
set_fs(KERNEL_DS); /* 사용자 주소 검증 비활성화! */
vfs_read(file, kernel_buf, count, &pos); /* 커널 버퍼에 직접 읽기 */
set_fs(old_fs);
/* Linux 5.18 이후 — 안전한 대안 */
kernel_read(file, kernel_buf, count, &pos);
kernel_write(file, kernel_buf, count, &pos);
/*
* kernel_read/kernel_write는 내부적으로
* __vfs_read/__vfs_write를 직접 호출하여
* access_ok() 검사를 우회합니다.
* set_fs()와 달리 전역 상태를 변경하지 않으므로 안전합니다.
*/
set_fs() 제거로 인해 커널 내부에서 사용자 공간 API(sys_*)를 직접 호출하는 것이 불가능해졌습니다. 모든 커널 내부 파일 접근은 kernel_read()/kernel_write()/filp_open() 등의 전용 API를 사용해야 합니다. 이는 SMAP/PAN과 결합하여 커널의 사용자 메모리 접근 보안을 크게 강화합니다.
호환성 계층
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 |
x32 ABI
x32 ABI는 x86_64 프로세서의 64비트 모드에서 32비트 포인터를 사용하는 특수한 ABI입니다. 64비트 레지스터와 명령어의 성능 이점을 유지하면서 포인터 크기를 줄여 캐시 효율을 높이는 것이 목적입니다.
여기서 중요한 점은 x32가 단순한 컴파일 옵션이 아니라 별도 ABI라는 사실입니다. 즉, 사용자 공간은 같은 명령어셋을 사용해도 포인터 크기, 구조체 레이아웃, 시스템 콜 번호 해석 규칙이 달라질 수 있습니다. ABI 전체 관점은 ABI 문서의 compat ABI 섹션에서 따로 정리합니다.
/* arch/x86/entry/common.c */
#define __X32_SYSCALL_BIT 0x40000000
static bool __do_fast_syscall_32(struct pt_regs *regs)
{
int nr = regs->ax;
/* x32 시스템 콜: 번호의 비트 30이 1 */
if (nr & __X32_SYSCALL_BIT) {
nr &= ~__X32_SYSCALL_BIT;
return do_syscall_x32(regs, nr);
}
return do_syscall_32(regs, nr);
}
/* x32 시스템 콜 테이블 */
/* arch/x86/entry/syscalls/syscall_64.tbl */
/* 512 x32 rt_sigaction compat_sys_rt_sigaction */
/* 513 x32 rt_sigreturn compat_sys_x32_rt_sigreturn */
/* x32 전용 시스템 콜은 512번부터 시작 */
CONFIG_X86_X32_ABI)는 채택률이 낮아 Linux 6.x에서 deprecated 논의가 진행 중입니다. 대부분의 배포판에서 기본 비활성화되어 있으며, 새 프로젝트에서는 사용을 권장하지 않습니다.
Y2038 문제와 시스템 콜 변경
32비트 time_t는 2038년 1월 19일 03:14:07 UTC에 오버플로됩니다. 이 문제를 해결하기 위해 Linux 5.1부터 64비트 시간을 사용하는 새로운 시스템 콜이 추가되었습니다.
| 기존 시스템 콜 (32비트 time_t) | 새 시스템 콜 (64비트 time_t) | 커널 버전 |
|---|---|---|
clock_gettime | clock_gettime64 | 5.1 |
clock_settime | clock_settime64 | 5.1 |
clock_nanosleep | clock_nanosleep_time64 | 5.1 |
futex | futex_time64 | 5.1 |
ppoll | ppoll_time64 | 5.1 |
pselect6 | pselect6_time64 | 5.1 |
recvmmsg | recvmmsg_time64 | 5.1 |
utimensat | utimensat_time64 | 5.1 |
time_t가 이미 64비트이므로 Y2038 문제가 없습니다. *_time64 시스템 콜은 주로 32비트 ARM, MIPS 등의 아키텍처를 위한 것입니다. glibc 2.32+는 32비트에서도 자동으로 *_time64 시스템 콜을 사용합니다.
compat_ioctl — 32비트 프로그램의 ioctl 호환
ioctl()은 디바이스 드라이버마다 고유한 명령어와 구조체를 사용하므로, 32비트/64비트 호환이 자동으로 이루어지지 않습니다. 구조체 내 포인터나 long 필드의 크기가 달라지기 때문입니다.
/* 문제 상황: 32비트 프로그램이 64비트 커널에 ioctl 호출 */
struct my_ioctl_data {
unsigned long flags; /* 32비트: 4바이트, 64비트: 8바이트! */
void *buffer; /* 32비트: 4바이트, 64비트: 8바이트! */
__u32 size; /* 동일: 4바이트 */
};
/* 해결: compat_ioctl 핸들러에서 32비트 구조체를 변환 */
struct compat_my_ioctl_data {
compat_ulong_t flags; /* 항상 4바이트 */
compat_uptr_t buffer; /* 항상 4바이트 */
__u32 size;
};
static long my_compat_ioctl(struct file *file,
unsigned int cmd, unsigned long arg)
{
struct compat_my_ioctl_data compat_data;
struct my_ioctl_data data;
if (copy_from_user(&compat_data, (void __user *)arg,
sizeof(compat_data)))
return -EFAULT;
/* 32비트 → 64비트 변환 */
data.flags = compat_data.flags;
data.buffer = compat_ptr(compat_data.buffer);
data.size = compat_data.size;
return my_ioctl_internal(file, cmd, &data);
}
/* file_operations 등록 */
static const struct file_operations my_fops = {
.unlocked_ioctl = my_ioctl,
.compat_ioctl = my_compat_ioctl, /* 32비트 호환 */
};
unsigned long, void * 대신 고정 크기 타입(__u32, __u64)만 사용하면 compat 핸들러가 필요 없습니다. 포인터는 __u64로 전달하세요.
vDSO (virtual Dynamic Shared Object)
개념과 목적
vDSO는 커널이 사용자 공간 프로세스의 주소 공간(Address Space)에 매핑하는 작은 공유 라이브러리(Shared Library)입니다. 커널 모드 전환 없이 특정 시스템 콜을 사용자 공간에서 직접 실행할 수 있게 합니다. 이를 통해 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 페이지(읽기 전용)를 사용자 공간에 매핑합니다. 타이머(Timer) 인터럽트마다 커널이 이 페이지를 갱신하고, 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 |
해당 스레드(Thread)만 종료 |
SECCOMP_RET_TRAP |
SIGSYS 시그널(Signal) 전송 (핸들링 가능) |
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가 권한 검사 결과를 기다리지 않고 투기적으로 커널 데이터를 캐시(Cache)에 올리기 때문에 발생합니다.
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가 분기 예측(Branch Prediction) 실패 시에도 투기적으로 실행하는 코드에서 비밀 데이터를 캐시 사이드채널로 유출하는 취약점입니다. 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 격리(Isolation) | 높음 (매 syscall마다) |
| IBPB | Indirect Branch Predictor Barrier: 컨텍스트 전환 시 BTB 플러시(Flush) | 중간 (컨텍스트 전환마다) |
| 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 + 하이퍼바이저(Hypervisor) 최적화 |
CONFIG_X86_64 기본 활성화). ARM64에서는 ASID(Address Space ID)가 동일한 역할을 합니다.
CR3 전환 코드 분석
KPTI가 활성화되면 시스템 콜 진입 시 SWITCH_TO_KERNEL_CR3 매크로가 실행되고, 반환 시 SWITCH_TO_USER_CR3가 실행됩니다. CR3 레지스터는 페이지 테이블의 물리 주소를 가리키므로, 이 전환이 사용자/커널 페이지 테이블 분리의 핵심입니다.
/* arch/x86/entry/calling.h (Linux 6.x, 단순화) */
/*
* 사용자 CR3 → 커널 CR3 전환
* 비트 12(PAGE_TABLE_ISOLATION 비트)를 클리어하여
* 커널 페이지 테이블 주소로 전환
*/
.macro SWITCH_TO_KERNEL_CR3 scratch_reg:req
mov %cr3, \scratch_reg
andq $(~PTI_USER_PGTABLE_AND_PCID_MASK), \scratch_reg
mov \scratch_reg, %cr3
.endm
/*
* 커널 CR3 → 사용자 CR3 전환
* 비트 12를 세팅하여 사용자 페이지 테이블 주소로 전환
* PCID가 지원되면 noflush 비트(63)도 세팅하여 TLB 플러시 방지
*/
.macro SWITCH_TO_USER_CR3_NOSTACK scratch_reg:req
mov %cr3, \scratch_reg
orq $(PTI_USER_PGTABLE_AND_PCID_MASK), \scratch_reg
/* PCID 지원 시: CR3 비트 63 = 1 → TLB noflush */
ALTERNATIVE "", "bts $63, \scratch_reg", X86_FEATURE_PCID
mov \scratch_reg, %cr3
.endm
사용자 페이지 테이블 트램펄린
사용자 페이지 테이블에는 커널 코드가 거의 매핑되지 않지만, SYSCALL 명령어가 최초로 점프하는 진입점(entry stub)은 반드시 사용자 PT에도 매핑되어야 합니다. 이 최소한의 매핑 영역을 트램펄린(Trampoline)이라 합니다.
/* arch/x86/mm/pti.c — 트램펄린 매핑 */
void __init pti_init(void)
{
/* 사용자 PT에 매핑하는 최소 영역들: */
/* 1. entry_SYSCALL_64 진입 stub (CR3 전환까지) */
/* 2. entry_SYSRETQ / entry_IRETQ 반환 stub */
/* 3. NMI, #DB 등 예외 핸들러 진입 stub */
/* 4. CPU entry area (per-CPU 데이터) */
pti_clone_entry_text(); /* .entry.text 섹션 복제 */
pti_clone_user_shared(); /* per-CPU entry area */
/* 사용자 PT에서 접근 가능한 커널 메모리:
* - 약 4KB의 진입/반환 코드
* - per-CPU TSS(Task State Segment)
* - GDT(Global Descriptor Table)
* - IDT(Interrupt Descriptor Table)
* → 전체 커널 이미지 대비 0.01% 미만
*/
}
PCID/ASID를 활용한 TLB 최적화
CR3 전환 시 기본적으로 전체 TLB가 플러시(Flush)되어 성능 저하가 큽니다. PCID(Process Context IDentifier, Intel Haswell+)를 사용하면 각 CR3 값에 PCID 태그를 부여하여, 다른 PCID의 TLB 엔트리를 유지할 수 있습니다.
/* arch/x86/mm/tlb.c (단순화) */
/*
* PCID 할당 전략:
* - 각 프로세스에 커널 PCID와 사용자 PCID 쌍을 할당
* - 커널 PCID = N, 사용자 PCID = N | 0x800
* - 총 4096개 PCID 중 사용 가능 개수는 제한적 (TLB 크기 고려)
*/
static void load_new_mm_cr3(pgd_t *pgdir,
u16 new_asid, bool need_flush)
{
unsigned long new_mm_cr3;
if (need_flush) {
/* TLB 플러시 필요: CR3 비트 63 = 0 */
new_mm_cr3 = build_cr3(pgdir, new_asid);
} else {
/* TLB 보존: CR3 비트 63 = 1 (noflush) */
new_mm_cr3 = build_cr3_noflush(pgdir, new_asid);
}
write_cr3(new_mm_cr3);
}
/*
* KPTI 성능 효과:
* - PCID 미지원: 매 시스템 콜마다 TLB 전체 플러시 → 30~50% 오버헤드
* - PCID 지원: TLB 보존 가능 → 1~5% 오버헤드 (대부분의 워크로드)
* - Skylake+: CR3 noflush 비트로 추가 최적화
*/
시스템 콜 관련 보안 취약점 사례
시스템 콜은 사용자-커널 경계를 넘는 유일한 공식 인터페이스이므로, 공격자에게 가장 중요한 공격 표면(Attack Surface)입니다. 주요 CVE를 시스템 콜 경로별로 분류합니다.
| CVE | 년도 | 공격 벡터 | 영향 | 커널 완화 |
|---|---|---|---|---|
CVE-2009-0029 |
2009 | 레지스터 상위 32비트 미정리 | 권한 상승 | SYSCALL_DEFINE 매크로의 long 캐스팅 도입 |
CVE-2014-4699 |
2014 | ptrace + SYSRET 비표준 RIP | 커널 코드 실행 | 비정규 RIP 검사 → IRET 폴백 |
CVE-2016-5195 |
2016 | madvise() + write() 경쟁 조건 (Dirty COW) | 읽기 전용 파일 쓰기 | get_user_pages() FOLL_COW 플래그 수정 |
CVE-2017-5754 |
2018 | 투기적 실행으로 커널 메모리 읽기 (Meltdown) | 커널 메모리 유출 | KPTI (페이지 테이블 분리) |
CVE-2017-5753 |
2018 | 배열 경계 투기적 우회 (Spectre v1) | 커널 메모리 유출 | array_index_nospec() 배리어 |
CVE-2017-5715 |
2018 | 간접 분기 예측 주입 (Spectre v2) | 커널 코드 실행 | Retpoline, IBRS, STIBP |
CVE-2019-1125 |
2019 | swapgs 투기적 실행 | 커널 메모리 유출 | swapgs 배리어 추가 |
CVE-2021-4154 |
2021 | cgroup v1 release_agent UAF | 컨테이너 탈출 | cgroup 참조 카운팅 수정 |
CVE-2022-0185 |
2022 | fsconfig() 힙 오버플로 | 컨테이너 탈출, 권한 상승 | legacy_parse_param() 길이 검사 수정 |
CVE-2022-0847 |
2022 | splice() 파이프 플래그 미초기화 (Dirty Pipe) | 읽기 전용 파일 쓰기 | copy_page_to_iter_pipe() 플래그 초기화 |
asmlinkage long sys_foo(int arg)에서 arg가 64비트 long으로 전달되면 상위 비트가 제어 흐름에 영향을 줄 수 있습니다. SYSCALL_DEFINE은 인자를 long으로 받은 뒤 명시적으로 올바른 타입으로 캐스팅하여 이 문제를 해결합니다.
madvise(MADV_DONTNEED)와 /proc/self/mem 쓰기를 동시에 실행하면, COW 페이지의 원본이 수정됩니다. /etc/passwd를 수정하여 root 권한을 획득하는 공격이 야생에서 발견되었습니다. 이 취약점은 2007년부터 존재했으며 9년간 발견되지 않았습니다.
시스템 콜 추적과 디버깅(Debugging)
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 페이지도 참고하세요.
추적 도구 오버헤드 정량 비교
getpid()를 1,000만 회 호출하는 벤치마크로 각 추적 도구의 오버헤드를 비교합니다. getpid()는 가장 단순한 시스템 콜이므로 순수 추적 오버헤드를 측정하기에 적합합니다.
| 추적 도구 | 메커니즘 | getpid() 평균 지연 | 오버헤드 배율 | 프로덕션 적합성 |
|---|---|---|---|---|
| 추적 없음 (baseline) | — | ~180ns | 1x | — |
ftrace tracepoint | 커널 tracepoint | ~190ns | 1.06x | 적합 |
bpftrace | eBPF + tracepoint | ~195ns | 1.08x | 적합 |
perf trace | perf_event + tracepoint | ~200ns | 1.11x | 적합 |
audit | audit 서브시스템 | ~350ns | 1.94x | 보안 감사용 |
strace | ptrace(PTRACE_SYSCALL) | ~15,000ns | 83x | 부적합 (디버깅 전용) |
/* 벤치마크 코드 */
#include <time.h>
#include <unistd.h>
#include <stdio.h>
int main(void) {
struct timespec start, end;
const int N = 10000000;
clock_gettime(CLOCK_MONOTONIC, &start);
for (int i = 0; i < N; i++)
getpid();
clock_gettime(CLOCK_MONOTONIC, &end);
double ns = (end.tv_sec - start.tv_sec) * 1e9
+ (end.tv_nsec - start.tv_nsec);
printf("avg: %.1f ns/call\n", ns / N);
}
bpftrace 또는 perf trace를 사용하세요. 두 도구 모두 커널 tracepoint를 사용하여 대상 프로세스를 중단시키지 않으며, 오버헤드가 10% 미만입니다. 보안 감사가 필요한 경우에만 audit 서브시스템을 활성화하세요.
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 모드에서는 커널 스레드(Kernel Thread)가 폴링(Polling)하여 시스템 콜 자체를 제거합니다.
| 방식 | 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 |
메모리 관리(Memory Management) |
| 네트워크 | socket, bind, listen, accept, connect, sendto, recvfrom |
네트워크 스택(Network Stack) |
| IPC | pipe, shmget, semget, msgget, eventfd, signalfd |
IPC 메커니즘 |
| 시그널 | kill, rt_sigaction, rt_sigprocmask, sigaltstack |
시그널 처리 |
| 파일시스템(Filesystem) 관리 | statfs, mount, umount2, pivot_root, chroot |
VFS, 네임스페이스(Namespace) |
| 시간 | 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 |
커널 모듈(Kernel Module), 디바이스 드라이버 |
| 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);
}
코드 설명
- MSR_STARSyscall Target Address Register입니다. 상위 32비트에 세그먼트 셀렉터(Segment Selector)를 인코딩합니다.
(__USER32_CS << 16) | __KERNEL_CS는SYSCALL진입 시 CS를__KERNEL_CS로 설정하고,SYSRET복귀 시 CS를__USER_CS로 복원하도록 지시합니다. - MSR_LSTARLong-mode SYSCALL Target Address Register(0xC0000082)입니다.
SYSCALL명령어 실행 시 RIP가 이 MSR 값으로 교체됩니다. 여기에entry_SYSCALL_64의 주소를 등록함으로써 모든 64비트 시스템 콜의 커널 진입점이 확정됩니다. - MSR_CSTARCompat-mode SYSCALL Target Address Register입니다. 64비트 커널에서 32비트 compat 프로세스의
SYSCALL을 처리합니다. 최신 커널은 compatSYSCALL을 완전히 비활성화하여ignore_sysret을 설정합니다. - MSR_SYSCALL_MASK
SYSCALL실행 시 하드웨어가 RFLAGS에서 자동으로 클리어하는 비트 마스크입니다.TF(트레이스),DF(방향),IF(인터럽트),AC(정렬 체크),NT(중첩 태스크)를 클리어합니다.IF클리어로 커널 진입 초기 인터럽트가 비활성화되어 스택 전환이 안전하게 수행됩니다.
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 |
중첩된 스택 프레임(Stack Frame) 안전 복원 |
/* 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) */
};
코드 설명
- nr현재 시스템 콜 번호입니다. BPF 필터에서
offsetof(struct seccomp_data, nr)= 0으로, 가장 첫 번째로 검사하는 필드입니다.AUDIT_ARCH_X86_64와 함께 특정 시스템 콜을 허용/거부하는 핵심 필터 기준입니다. - arch아키텍처 식별자입니다.
AUDIT_ARCH_X86_64(0xC000003E),AUDIT_ARCH_I386(0x40000003) 등AUDIT_ARCH_*상수를 담습니다. 필터가 이 필드를 먼저 검증하지 않으면 x32 ABI나 32비트 호환 경로를 통한 필터 우회가 가능합니다. - instruction_pointer시스템 콜을 발생시킨 명령어의 주소입니다. 어느 코드 위치에서 시스템 콜이 호출되었는지 추적할 때 활용합니다. 화이트리스트 기반 샌드박스에서 허용된 코드 영역(예: vDSO, libc)의 IP 범위를 필터링하는 데 사용할 수 있습니다.
- args[6]시스템 콜 인자 6개(arg0~arg5)를
u64배열로 담습니다. 인자 기반 세밀한 제어, 예를 들어open()의 플래그 값이나mmap()의 보호 비트를 검사할 때 사용합니다. 포인터 인자는 주소값만 알 수 있고 역참조(dereference)는 불가합니다.
arch 필드를 검증해야 합니다. 그렇지 않으면 공격자가 32비트 호환 모드(int 0x80)로 다른 번호 체계의 시스템 콜을 호출하여 필터를 우회할 수 있습니다. 예를 들어 x86_64에서 __NR_write는 1이지만, 32비트에서는 4입니다.
seccomp_filter 구조체 심층 분석
struct seccomp_filter는 개별 BPF 필터 프로그램을 나타내며, 프로세스는 이 구조체의 연결 리스트(Linked List)를 통해 필터 체인을 유지합니다. task_struct의 seccomp.filter 포인터가 가장 최근에 설치된 필터를 가리키고, 각 필터는 이전 필터를 prev로 참조합니다.
/* kernel/seccomp.c */
struct seccomp_filter {
refcount_t usage; /* 참조 카운트 (fork 시 공유) */
bool is_dead; /* 프로세스 종료 시 무효화 플래그 */
struct task_struct __rcu *notif_task; /* USER_NOTIF 수신자 태스크 */
struct seccomp_filter *prev; /* 이전 필터(부모 체인) */
struct bpf_prog *prog; /* JIT 컴파일된 BPF 프로그램 */
struct notification *notif; /* USER_NOTIF fd 알림 구조체 */
struct mutex notify_lock; /* notif 접근 직렬화 */
wait_queue_head_t wqh; /* USER_NOTIF 대기 큐 */
};
코드 설명
- usage원자적(Atomic) 참조 카운터입니다.
fork()시 자식 프로세스가 부모의 필터 체인을 공유하기 때문에 참조 카운팅이 필요합니다. 카운터가 0이 되면 필터와 BPF 프로그램 메모리가 해제됩니다. - is_dead프로세스 그룹이 종료 중일 때 설정됩니다. 이미 종료된 프로세스의 필터가
USER_NOTIF를 통해 신호를 기다리는 supervisor에게 즉시 에러를 반환하도록 합니다. - prev필터 체인의 이전 노드를 가리킵니다.
seccomp_run_filters()는 현재 필터부터 역방향으로 순회하여 모든 필터를 실행합니다. 가장 엄격한(최근에 설치된) 필터가 먼저 실행되며, 체인 중 하나라도 KILL/ERRNO를 반환하면 즉시 종료됩니다. - progcBPF를 커널이 JIT 변환한
struct bpf_prog포인터입니다.seccomp_run_filters()에서BPF_PROG_RUN(filter->prog, sd)로 호출됩니다. JIT가 비활성화된 환경에서는 BPF 인터프리터로 실행합니다. - notif, notify_lock, wqh
SECCOMP_RET_USER_NOTIF기능을 위한 필드들입니다.notif는 사용자 공간 supervisor와 통신하는 알림 구조체이고,notify_lock은 동시 접근을 직렬화하며,wqh는 supervisor가 응답을 기다리는 대기 큐입니다.
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 로더(Loader)가 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 진입점을 사용합니다.
RISC-V 시스템 콜 구현
RISC-V 아키텍처는 ecall(Environment Call) 명령어로 시스템 콜을 호출합니다. x86_64의 SYSCALL, ARM64의 SVC #0에 대응하는 명령어입니다. RISC-V의 시스템 콜 인터페이스는 상대적으로 단순하며, x86_64와 달리 ecall 전후 레지스터 저장/복원에 대한 하드웨어 지원이 없어 소프트웨어에서 모두 처리합니다.
ecall 레지스터 규약
| 역할 | RISC-V 레지스터 | x86_64 대응 | ARM64 대응 |
|---|---|---|---|
| 시스템 콜 번호 | a7 | rax | x8 |
| 인자 1 | a0 | rdi | x0 |
| 인자 2 | a1 | rsi | x1 |
| 인자 3 | a2 | rdx | x2 |
| 인자 4 | a3 | r10 | x3 |
| 인자 5 | a4 | r8 | x4 |
| 인자 6 | a5 | r9 | x5 |
| 반환값 | a0 | rax | x0 |
| 에러 플래그 | a0 (음수) | rax (음수) | x0 (음수) |
ecall 진입 경로 분석
RISC-V에서 ecall은 Environment Call exception을 발생시키며, 트랩 핸들러가 stvec 레지스터에서 지정한 주소로 점프합니다.
/* RISC-V 사용자 공간에서 시스템 콜 호출 */
li a7, 64 /* __NR_write = 64 */
li a0, 1 /* fd = STDOUT */
la a1, msg /* buf = 메시지 주소 */
li a2, 13 /* count = 13 */
ecall /* 트랩 발생 → S-mode 전환 */
/* a0에 반환값 (쓴 바이트 수 또는 음수 에러) */
/* arch/riscv/kernel/entry.S — 트랩 진입점 (단순화) */
ENTRY(handle_exception)
/* 1. sscratch ↔ tp 스왑 (커널 스레드 포인터 획득) */
csrrw tp, sscratch, tp
/* 2. 커널 스택에 pt_regs 저장 */
REG_S sp, PT_SP(tp)
REG_S ra, PT_RA(tp)
REG_S a0, PT_A0(tp)
/* ... 나머지 레지스터 저장 ... */
/* 3. 예외 원인 확인: scause == EXC_SYSCALL */
csrr t0, scause
li t1, EXC_SYSCALL
beq t0, t1, handle_syscall
/* 기타 예외 처리... */
handle_syscall:
/* 4. sepc += 4 (ecall 다음 명령어로 복귀) */
addi s2, s2, 0x4
REG_S s2, PT_EPC(sp)
/* 5. C 핸들러 호출 */
jal do_trap_ecall_u
END(handle_exception)
/* arch/riscv/kernel/traps.c */
void do_trap_ecall_u(struct pt_regs *regs)
{
syscall_enter_from_user_mode(regs);
long nr = regs->a7; /* 시스템 콜 번호 */
if (likely(nr < NR_syscalls)) {
regs->a0 = sys_call_table[nr](regs);
} else {
regs->a0 = -ENOSYS;
}
syscall_exit_to_user_mode(regs);
}
3대 아키텍처 시스템 콜 비교
| 항목 | x86_64 | ARM64 | RISC-V |
|---|---|---|---|
| 진입 명령어 | SYSCALL | SVC #0 | ecall |
| 명령어 크기 | 2바이트 | 4바이트 | 4바이트 |
| 특권 전환 | Ring 3→0 | EL0→EL1 | U-mode→S-mode |
| 번호 레지스터 | rax | x8 | a7 |
| 반환 레지스터 | rax | x0 | a0 |
| 복귀 주소 저장 | rcx (HW) | ELR_EL1 (HW) | sepc (SW +4) |
| 플래그 저장 | r11 (HW) | SPSR_EL1 (HW) | sstatus (HW) |
| 반환 명령어 | SYSRET/IRET | ERET | sret |
| 벡터 테이블 | MSR_LSTAR | VBAR_EL1 | stvec |
| 디스패처 | do_syscall_64() | invoke_syscall() | do_trap_ecall_u() |
| HW 레지스터 저장 | rcx, r11만 | ELR, SPSR만 | 없음 (전부 SW) |
| uaccess 보호 | SMAP/SMEP | PAN/PXN | SUM (sstatus 비트) |
| vDSO 지원 | 예 | 예 | 예 |
SYSCALL은 rcx, r11을 하드웨어가 자동 저장하고 MSR 기반으로 즉시 커널 코드로 점프합니다. ARM64의 SVC #0은 ELR_EL1, SPSR_EL1을 하드웨어가 저장합니다. 반면 RISC-V의 ecall은 sepc만 하드웨어가 저장하고 나머지는 모두 소프트웨어가 처리합니다. 이는 RISC-V의 "최소 하드웨어, 최대 소프트웨어" 철학을 반영합니다.
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 | 여러 버퍼(Buffer)의 scatter/gather I/O | N개 read/write → 1 syscall | 데이터베이스, 로그 서버 |
| splice/tee | 커널 내 zero-copy 데이터 이동 | read+write → 1 syscall | 프록시 서버, 파이프라인(Pipeline) |
| epoll | 이벤트 기반 다중 fd 모니터링 | N개 poll → 1 syscall | 이벤트 루프(Event Loop), 웹 서버 |
| 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 오버헤드를 최소화할 수 있습니다.
참고 자료
커널 공식 문서
- Adding a New System Call — 새로운 시스템 콜을 커널에 추가하는 절차와 설계 지침을 설명하는 공식 문서입니다.
- Syscall User Dispatch — 사용자 공간에서 시스템 콜을 가로채 에뮬레이션할 수 있는 syscall_user_dispatch 메커니즘을 다룹니다.
- Seccomp BPF (SECure COMPuting with filters) — BPF 필터를 사용하여 프로세스의 시스템 콜을 제한하는 seccomp 메커니즘의 공식 문서입니다.
- x86_64 Entry Points — x86_64 아키텍처의 시스템 콜 진입점(entry_SYSCALL_64)과 예외 처리 경로를 설명합니다.
- KVM API — ioctl 기반 시스템 콜 — ioctl 시스템 콜을 통해 KVM 하이퍼바이저와 상호작용하는 방법을 보여주는 대표적 사례입니다.
LWN.net 기사
- Anatomy of a system call, part 1 (2014) — 시스템 콜이 사용자 공간에서 커널까지 도달하는 전체 경로를 상세히 분석한 기사입니다.
- Anatomy of a system call, part 2 (2014) — x86_64에서 SYSCALL 명령어, MSR 레지스터 설정, entry_SYSCALL_64 진입 경로를 심층적으로 다룹니다.
- A vDSO update (2014) — vDSO(Virtual Dynamic Shared Object)의 동작 원리와 gettimeofday 등의 최적화 메커니즘을 설명합니다.
- Seccomp and sandboxing (2014) — seccomp-bpf를 활용한 시스템 콜 필터링과 샌드박싱 기법을 다룹니다.
- Closing the door on new system calls (2020) — 시스템 콜 설계 시 주의해야 할 ABI 호환성 문제와 모범 사례를 논의합니다.
- Emulating Windows system calls in Linux (2019) — syscall_user_dispatch의 배경이 된 Windows 시스템 콜 에뮬레이션 논의를 다룹니다.
- Rethinking the UAPI header split (2021) — 사용자 공간 API(UAPI) 헤더 관리와 시스템 콜 번호 정의 구조를 설명합니다.
man 페이지
- syscalls(2) — 리눅스에서 사용 가능한 모든 시스템 콜의 목록과 각 버전별 추가 이력을 제공합니다.
- syscall(2) — 아키텍처별 시스템 콜 호출 규약(레지스터 매핑, 반환값 처리)을 설명합니다.
- prctl(2) — 프로세스 속성을 제어하는 다목적 시스템 콜로, seccomp 설정 등에 사용됩니다.
- vdso(7) — vDSO의 개요, 지원 함수 목록, 아키텍처별 차이점을 설명합니다.
- seccomp(2) — seccomp 시스템 콜의 사용법과 BPF 필터 프로그램 작성 방법을 다룹니다.
- ptrace(2) — 시스템 콜 추적(PTRACE_SYSCALL)과 디버깅에 사용되는 ptrace 인터페이스를 설명합니다.
커널 소스 (Bootlin Elixir)
- arch/x86/entry/entry_64.S — x86_64 시스템 콜 진입점 entry_SYSCALL_64의 어셈블리 구현입니다.
- arch/x86/entry/common.c — do_syscall_64() 함수가 정의되어 있으며, sys_call_table 디스패치와 ptrace/seccomp 훅 호출이 이루어집니다.
- arch/x86/entry/syscall_64.c — x86_64 sys_call_table 배열이 정의된 파일입니다.
- include/linux/syscalls.h — SYSCALL_DEFINE 매크로와 모든 시스템 콜의 함수 선언이 포함된 헤더입니다.
- arch/arm64/kernel/sys.c — ARM64 아키텍처의 시스템 콜 테이블이 정의된 파일입니다.
- arch/x86/entry/vdso/ — x86 vDSO 구현 디렉터리로, vgettimeofday.c 등 커널 진입 없이 실행되는 함수가 포함되어 있습니다.
- kernel/seccomp.c — seccomp BPF 필터의 커널 측 구현으로, 시스템 콜 진입 시 필터 평가 로직이 포함되어 있습니다.
- include/uapi/asm-generic/unistd.h — 아키텍처 공통 시스템 콜 번호 정의 파일입니다. 새 시스템 콜 추가 시 이 파일이 갱신됩니다.
관련 문서
시스템 콜과 관련된 다른 주제를 더 깊이 이해하고 싶다면 다음 문서를 참고하세요.