defense php2shell seccomp ebpf defense

제로 트러스트 PHP-FPM: Linux Seccomp-BPF와 eBPF 기반 커널 시스템콜 강화

php2shell.run Research Lab •

제로 트러스트 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 체인의 마지막 단계를 무력화할 수 있습니다. 그러나 이것은 심층 방어의 한 계층일 뿐이며, 패치와 엔진 설정이 항상 우선입니다.