제로 트러스트 PHP-FPM: Linux Seccomp-BPF와 eBPF 기반 커널 시스템콜 강화
제로 트러스트 PHP-FPM: Linux Seccomp-BPF와 eBPF 기반 커널 시스템콜 강화
<time datetime="2026-10-23">검증일: 2026년 10월 23일</time>
PHP2Shell(CVE-2026-109618, CVE-2026-109619)의 최종 단계는 결국 프로세스 생성과 명령 실행입니다. 패치가 최선이지만, 레거시 환경이나 EOL 브랜치를 즉시 교체할 수 없다면 커널 경계에서 execve를 차단하는 것이 강력한 보험입니다. 이 가이드는 Seccomp-BPF 프로파일과 eBPF 기반 관측을 결합하여 "RCE가 성립하더라도 셸이 뜨지 않는" 구성을 설명합니다.
1. 시스템콜 차단 아키텍처
PHP-FPM 워커
│
│ system("curl ... | sh")
▼
┌─────────────────────────────────────────────┐
│ glibc: fork() / clone() / execve() │
└──────────────────┬──────────────────────────┘
▼
┌─────────────────────────────────────────────┐
│ Seccomp-BPF 필터 (커널 내부) │
│ - execve → SCMP_ACT_ERRNO(EPERM) │
│ - execveat→ SCMP_ACT_ERRNO(EPERM) │
│ - clone/fork → 제한 또는 허용 목록 기반 │
└──────────────────┬──────────────────────────┘
▼
[ EPERM ] 셸 생성 실패, RCE 체인 중단
중요한 점은 Seccomp가 되돌릴 수 없는 커널 필터라는 것입니다. no_new_privs가 설정되면 프로세스가 스스로 필터를 제거할 수 없습니다.
2. 프로덕션 Seccomp 허용 목록 프로파일
{
"defaultAction": "SCMP_ACT_ERRNO",
"architectures": ["SCMP_ARCH_X86_64", "SCMP_ARCH_X86"],
"syscalls": [
{ "names": ["read","write","close","fstat","lstat","stat","poll","lseek",
"mmap","mprotect","munmap","brk","rt_sigaction","rt_sigprocmask",
"rt_sigreturn","ioctl","pread64","pwrite64","readv","writev",
"access","pipe","select","sched_yield","mremap","msync",
"mincore","madvise","shmget","shmat","shmctl","dup","dup2",
"nanosleep","getitimer","alarm","setitimer","getpid","sendfile",
"socket","connect","accept","sendto","recvfrom","sendmsg",
"recvmsg","shutdown","bind","listen","getsockname","getpeername",
"socketpair","setsockopt","getsockopt","clone","fork","vfork",
"execve","exit","wait4","kill","uname","fcntl","flock","fsync",
"fdatasync","truncate","ftruncate","getdents","getcwd","chdir",
"fchdir","rename","mkdir","rmdir","creat","link","unlink",
"symlink","readlink","chmod","fchmod","chown","fchown",
"lchown","umask","gettimeofday","getrlimit","getrusage",
"sysinfo","times","ptrace","getuid","getgid","setuid","setgid",
"geteuid","getegid","setpgid","getppid","getpgrp","setsid",
"setreuid","setregid","getgroups","setgroups","setresuid",
"getresuid","setresgid","getresgid","getpgid","setfsuid",
"setfsgid","getsid","capget","capset","rt_sigpending",
"rt_sigtimedwait","rt_sigqueueinfo","rt_sigsuspend",
"sigaltstack","utime","mknod","uselib","personality",
"ustat","statfs","fstatfs","sysfs","getpriority","setpriority",
"sched_setparam","sched_getparam","sched_setscheduler",
"sched_getscheduler","sched_get_priority_max",
"sched_get_priority_min","sched_rr_get_interval","mlock",
"munlock","mlockall","munlockall","vhangup","modify_ldt",
"pivot_root","_sysctl","prctl","arch_prctl","adjtimex",
"setrlimit","chroot","sync","acct","settimeofday","mount",
"umount2","swapon","swapoff","reboot","sethostname",
"setdomainname","iopl","ioperm","init_module","delete_module",
"quotactl","gettid","readahead","setxattr","lsetxattr",
"fsetxattr","getxattr","lgetxattr","fgetxattr","listxattr",
"llistxattr","flistxattr","removexattr","lremovexattr",
"fremovexattr","tkill","time","futex","sched_setaffinity",
"sched_getaffinity","set_thread_area","io_setup","io_destroy",
"io_getevents","io_submit","io_cancel","get_thread_area",
"lookup_dcookie","epoll_create","epoll_ctl_old",
"epoll_wait_old","remap_file_pages","getdents64",
"set_tid_address","restart_syscall","semtimedop","fadvise64",
"timer_create","timer_settime","timer_gettime",
"timer_getoverrun","timer_delete","clock_settime",
"clock_gettime","clock_getres","clock_nanosleep",
"exit_group","epoll_wait","epoll_ctl","tgkill","utimes",
"mbind","set_mempolicy","get_mempolicy","mq_open",
"mq_unlink","mq_timedsend","mq_timedreceive","mq_notify",
"mq_getsetattr","kexec_load","waitid","add_key","request_key",
"keyctl","ioprio_set","ioprio_get","inotify_init",
"inotify_add_watch","inotify_rm_watch","migrate_pages",
"openat","mkdirat","mknodat","fchownat","futimesat",
"newfstatat","unlinkat","renameat","linkat","symlinkat",
"readlinkat","fchmodat","faccessat","pselect6","ppoll",
"unshare","set_robust_list","get_robust_list","splice",
"tee","sync_file_range","vmsplice","move_pages","utimensat",
"epoll_pwait","signalfd","timerfd_create","eventfd",
"fallocate","timerfd_settime","timerfd_gettime",
"accept4","signalfd4","eventfd2","epoll_create1","dup3",
"pipe2","inotify_init1","preadv","pwritev","rt_tgsigqueueinfo",
"perf_event_open","recvmmsg","fanotify_init","fanotify_mark",
"prlimit64","name_to_handle_at","open_by_handle_at",
"clock_adjtime","syncfs","sendmmsg","setns","getcpu",
"process_vm_readv","process_vm_writev","kcmp","finit_module",
"sched_setattr","sched_getattr","renameat2","seccomp",
"getrandom","memfd_create","kexec_file_load","bpf",
"execveat","userfaultfd","membarrier","mlock2","copy_file_range",
"preadv2","pwritev2","pkey_mprotect","pkey_alloc","pkey_free",
"statx","io_pgetevents","rseq"],
"action": "SCMP_ACT_ALLOW"
}
]
}
위 예시는 허용 목록 방식이며 execve가 포함되어 있습니다. 실제 제로 트러스트 구성에서는 다음처럼 execve와 execveat을 명시적으로 차단해야 합니다.
{
"defaultAction": "SCMP_ACT_ALLOW",
"syscalls": [
{ "names": ["execve", "execveat"], "action": "SCMP_ACT_ERRNO", "errnoRet": 1 },
{ "names": ["clone","fork","vfork"], "action": "SCMP_ACT_ERRNO", "errnoRet": 11 }
]
}
clone 차단은 일부 확장이 스레드를 생성할 때 문제를 일으킬 수 있습니다. 먼저 execve만 차단하고, 워크로드 특성에 따라 범위를 넓히십시오.
3. 컨테이너와 Kubernetes 배포
3.1 Docker
sudo mkdir -p /etc/docker/seccomp
sudo cp php-fpm-seccomp.json /etc/docker/seccomp/php-fpm.json
docker run -d --name php-app \
--security-opt seccomp=/etc/docker/seccomp/php-fpm.json \
--security-opt no-new-privileges:true \
--read-only \
--tmpfs /tmp:rw,noexec,nosuid,size=64m \
-v /var/www/html:/var/www/html:ro \
-v /var/lib/php/sessions:/var/lib/php/sessions:rw \
php:8.3-fpm
3.2 Kubernetes
apiVersion: v1
kind: Pod
metadata:
name: php-fpm
spec:
securityContext:
seccompProfile:
type: Localhost
localhostProfile: profiles/php-fpm-seccomp.json
containers:
- name: php
image: php:8.3-fpm
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumeMounts:
- name: sessions
mountPath: /var/lib/php/sessions
- name: tmp
mountPath: /tmp
volumes:
- name: sessions
emptyDir: {}
- name: tmp
emptyDir: { medium: Memory }
localhostProfile 경로는 노드의 /var/lib/kubelet/seccomp/ 기준입니다.
4. eBPF 기반 관측과 경보
Seccomp가 예방이라면 eBPF는 관측입니다. tetragon 또는 bpftrace로 execve 시도 자체를 기록할 수 있습니다.
# bpftrace: php-fpm의 execve 시도 추적
sudo bpftrace -e '
tracepoint:syscalls:sys_enter_execve
/ comm == "php-fpm" || comm == "php-fpm8.3" /
{
printf("%s pid=%d comm=%s args=%s\n",
strftime("%H:%M:%S", nsecs), pid, comm, str(args->filename));
}'
# Tetragon TracingPolicy (개념)
apiVersion: cilium.io/v1alpha1
kind: TracingPolicy
metadata:
name: php-fpm-exec
spec:
kprobes:
- call: "sys_execve"
syscall: true
args:
- index: 0
type: "string"
selectors:
- matchPIDs:
- operator: In
values: [33]
matchActions:
- action: Post
eBPF 경보는 차단이 아니라 가시성을 제공합니다. Seccomp로 차단하고 eBPF로 시도 흔적을 남기는 조합이 가장 실용적입니다.
5. 배포 전 검증
# 1) Seccomp 프로파일 문법 검사
sudo docker run --rm \
--security-opt seccomp=/etc/docker/seccomp/php-fpm.json \
php:8.3-fpm php -v
# 2) 차단 동작 확인 (컨테이너 내부)
sudo docker exec php-app sh -c 'echo test' || echo "execve blocked as expected"
# 3) 정상 기능 회귀 테스트
curl -sS https://example.com/health -o /dev/null -w '%{http_code}\n'
curl -sS -F "file=@/etc/hostname" https://example.com/upload/ -o /dev/null -w '%{http_code}\n'
# 4) 감사 로그 확인
sudo journalctl -k --since "10 min ago" | grep -i seccomp
6. 엔지니어 FAQ
Q1. 정상적인 이미지 처리나 메일 발송이 깨지지 않나요?
ImageMagick, wkhtmltopdf, sendmail 등 외부 프로세스를 호출하는 기능은 execve 차단과 충돌합니다. 해당 기능을 별도 워커(예: 큐 기반 사이드카)로 분리하고, 메인 PHP-FPM 풀에서만 execve를 차단하십시오.
Q2. 운영 환경에서 안전하게 테스트하려면?
카나리 파드/노드에서 SCMP_ACT_LOG(차단 대신 기록)로 시작하여 필요한 시스템콜을 수집한 뒤, 허용 목록을 확정하고 SCMP_ACT_ERRNO로 전환하십시오.
Q3. Seccomp가 있으면 패치가 불필요한가요?
아닙니다. Seccomp는 execve 이후 단계를 막을 뿐, 메모리 손상 자체와 정보 유출, 서비스 거부는 막지 못합니다. 반드시 8.5.12 / 8.4.27 / 8.3.36 / 8.2.35 이상으로 업그레이드하고, session.upload_progress.enabled=Off를 병행하십시오.
Q4. no_new_privs는 어떻게 확인하나요?
grep NoNewPrivs /proc/$(pgrep -f 'php-fpm: pool' | head -1)/status
값이 1이면 활성화 상태입니다.
7. 결론
제로 트러스트 PHP-FPM 구성은 "침해되어도 명령이 실행되지 않는다" 는 가정을 커널 수준에서 강제합니다. Seccomp-BPF 허용 목록, no_new_privs, 읽기 전용 루트 파일시스템, eBPF 관측을 조합하면 PHP2Shell 체인의 마지막 단계를 무력화할 수 있습니다. 그러나 이것은 심층 방어의 한 계층일 뿐이며, 패치와 엔진 설정이 항상 우선입니다.