嘿,各位 PHP 精英和那些觉得自己在写代码但其实在玩俄罗斯轮盘赌的“代码艺术家”们!
欢迎来到今天的黑客大会现场,我是你们的特邀讲师——老王。今天我们要聊的东西有点硬核,甚至有点“反人性”,那就是:如何在 PHP 的荒原上构建一座铜墙铁壁,把那些试图对你的代码指手画脚的攻击者拒之门外。
我们要探讨的主题是:物理隔离下的 RCE 攻击防御——利用 open_basedir 与只读容器构建防弹级运行环境。
别紧张,我不是来教你们怎么黑人的,我是来教你们怎么给自己穿上一件写着“本宝宝很安全”的防弹衣。如果你的代码曾经被 RCE(远程代码执行)搞得夜不能寐,如果你的服务器曾经被勒索病毒逼得怀疑人生,或者如果你只是单纯地喜欢在极其受限的环境里展示技术实力,那么这场讲座就是为你准备的。
准备好了吗?让我们把咖啡因灌下去,开始今天的“监狱建筑师”之旅。
第一章:PHP 的阿喀琉斯之踵——为什么我们需要“物理隔离”?
首先,我们得承认一个事实:PHP 是个很棒的脚本语言。它简单、易学,就像一把瑞士军刀,不仅能切火腿肠,还能砍电线杆。但是,它也有个致命的缺点:太容易“失控”了。
在 Web 安全的世界里,RCE(Remote Code Execution,远程代码执行)是神一样的存在。一旦攻击者拿到了 RCE 权限,恭喜你,你的服务器就从“Web 应用”变成了“远程桌面”。你可以像玩《我的世界》一样,在这个服务器上建房子、炸房子,或者把你的 .env 配置文件当成烟花放。
传统的防御手段是什么?防火墙、WAF(Web应用防火墙)、IDS(入侵检测系统)。这些东西就像是你在房子门口挂了几个结实的铁栅栏,甚至装了摄像头。但这够吗?
不够。只要你的 PHP 代码里有个漏洞,比如一处未过滤的用户输入被用在了 include 或者 system 函数里,那么你的防火墙、摄像头、铁栅栏,在黑客面前就等同于透明。
黑客不需要翻墙,他们只需要找个“内鬼”。
所以,我们要搞“物理隔离”。这里说的“物理隔离”,不是把你电脑拔了线扔到地下室,而是把你的应用运行环境彻底容器化,并且限制它的生存能力。我们要让它像个被困在太空舱里的宇航员:不能乱跑,不能乱动,不能乱写。
这听起来很残忍,但对代码来说,这叫“约束之美”。
第二章:open_basedir —— PHP 的冰箱门锁
在进入 Docker 的深水区之前,我们先聊聊 PHP 配置里的一个常青树——open_basedir。
想象一下,你的 PHP 进程是一个饥饿的狼。默认情况下,如果它饿了,它可以去冰箱(/var/www/html),可以去粮仓(/etc),甚至可以去邻居家的储藏室(/tmp,通常是没有限制的)。
open_basedir 就是主人给这只狼脖子上套的一个项圈。你告诉它:“狼啊,你只能去 A 舱和B 舱找吃的,其他地方别去,去就去没命。”
配置示例:
; php.ini 文件
open_basedir = /var/www/html:/tmp
这段配置的意思是:当前 PHP 脚本只能访问 /var/www/html(通常是网站根目录)和 /tmp(临时目录)。如果你尝试读取 /etc/passwd 或者写入 /var/log/syslog,PHP 会直接扔给你一个错误,比如:
Warning: file_get_contents(/etc/passwd): failed to open stream: Operation not permitted in ...
它能防 RCE 吗?
在 90% 的情况下,是的。大多数基于文件包含的 RCE(比如 include($_GET['file']))都依赖于读取敏感文件或者包含外部文件。open_basedir 直接切断了这条路。
但是,它有个致命的漏洞。
如果攻击者能利用其他方式执行命令(比如 SQL 注入堆叠查询,或者命令注入),那么 open_basedir 就毫无用处。它只是限制了“看”,没限制“做”。
而且,如果你的 PHP 版本有漏洞(比如 2016 年爆出的 php://filter 伪协议 RCE),攻击者甚至能绕过 open_basedir 去读取系统文件。所以,光有 open_basedir,就像给狼戴上了项圈,但没把它关进笼子。
第三章:只读容器 —— 把代码扔进金库
现在,我们要升级防御策略。我们要把 PHP 进程放入一个“只读容器”中。
什么是只读容器?简单来说,就是这个容器里的操作系统(OS)和文件系统是只读的。除了我们特意允许写入的地方(比如一个名为 /var/run 的挂载卷),任何试图修改文件系统、写入恶意脚本、删除日志的行为都会被内核直接拦截。
这就好比你把代码放进了一个带锁的保险箱,而且保险箱本身是焊死的。
3.1 为什么这能防 RCE?
一旦容器启动,文件系统就是只读的。
- 无持久化后门: 攻击者如果通过 RCE 写入了一个 webshell(比如
hack.php),当容器重启后,这个文件会直接消失。 - 无挖矿矿工: 攻击者很难在只读文件系统中安装矿机程序。
- 无日志擦除: 攻击者不能删除系统日志来掩盖踪迹。
3.2 如何实现?—— Docker 命令的艺术
我们来看一段神奇的 Docker 启动命令。这不仅仅是一个命令,这是通往“防弹级”世界的护照。
docker run -d
--name php-hardened-app
--read-only
--cap-drop ALL
--cap-add CHOWN
--cap-add DAC_OVERRIDE
--security-opt no-new-privileges
--security-opt seccomp=seccomp-profile.json
--volume /tmp
--volume /var/run
-v my-app-data:/var/www/html
-p 80:80
my-php-image
让我们逐行解读这行咒语:
--read-only:这是核心!告诉 Docker 内核,这个容器的根文件系统是只读的。谁也别想改。--cap-drop ALL:卸载所有 Linux 能力。比如CAP_SYS_ADMIN,这可是黑客最喜欢的能力,有了它就能挂载、卸载文件系统。既然是只读环境,我们肯定不需要这个能力。--cap-add CHOWN:我们需要保留少数几个能力。为什么要CHOWN?因为 PHP 在某些情况下可能需要给文件改名或者改变所有者,特别是在生成临时文件或者某些框架的文件上传处理中。但这只是临时权限,只给必要的。--security-opt no-new-privileges:这招能防止容器内的用户提权。如果容器里有个恶意脚本试图su root或者使用setuid命令,这条命令会直接毙掉它。--volume /tmp:注意,我们只挂载了/tmp目录!虽然容器是只读的,但/tmp目录被挂载为读写模式。为什么?因为某些 PHP 扩展或者系统进程可能会用到它。--volume /var/run:通常用于 socket 文件(比如 Redis、Docker 守护进程的 socket)的读写,这些也是只读容器运行所必需的。-v my-app-data:/var/www/html:这是你应用的数据目录。通过命名卷,我们可以让应用在容器重启后依然保留数据,但这个卷是持久化的,不是写入容器的镜像文件系统里的。
第四章:攻防演练——当黑客遇到铁桶
现在,让我们假设一个场景。你正在运行上面那个只读容器,并且开启了 open_basedir。
场景一:文件包含攻击
黑客尝试发送请求:
GET /index.php?file=/etc/passwd
防御系统反应:
- PHP 接收请求。
open_basedir检查/etc/passwd。拒绝! 错误:open_basedir restriction in effect.- 黑客傻眼,文件被拦截了。
场景二:WebShell 上传
黑客尝试通过上传功能上传一个 webshell.php 并写入一句话木马。
防御系统反应:
- 文件上传成功,但是上传到了哪里?
- 由于根文件系统是只读的,
/var/www/html/uploads目录通常是容器的一部分,是只读的。文件上传失败。 - 如果黑客强行写入,内核报错:
Read-only file system。
场景三:命令注入
黑客通过表单提交了一个参数:
POST /search.php?q=whoami; echo '<script>alert(1)</script>'
防御系统反应:
- 假设你的代码中使用了
shell_exec($_POST['q'])。 shell_exec会执行命令。- 等等! 这时候,只读容器和
open_basedir还有用吗?看起来没用了,因为shell_exec是在执行系统命令,不是在读写文件。 - 关键点来了: 如果你的代码逻辑是安全的(没有使用危险的函数),那么 RCE 就不会发生。但是,如果我们想从基础设施层面杜绝
shell_exec,我们可以使用seccomp(Linux 安全计算模式)。
4.1 添加 seccomp:最后的底牌
我们之前在 Docker 命令里加了一行:--security-opt seccomp=seccomp-profile.json。
这是一个白名单配置。它告诉内核:“这个容器只能调用 open(), read(), write() 等极其基础的文件操作,禁止调用 execve() 等执行命令的系统调用。”
如果我们把 seccomp 配置成完全禁止执行命令的模式,那么 shell_exec, system, passthru 等函数都将失效。PHP 会报错:Warning: shell_exec() has been disabled for security reasons(或者在某些内核配置下直接无法调用系统调用)。
seccomp 配置示例(JSON):
{
"defaultAction": "SCMP_ACT_ERRNO",
"architectures": [
"SCMP_ARCH_X86_64"
],
"syscalls": [
{
"names": [
"open",
"read",
"write",
"close",
"stat",
"fstat",
"lstat",
"poll",
"lseek",
"mmap",
"mprotect",
"munmap",
"brk",
"rt_sigaction",
"rt_sigprocmask",
"rt_sigreturn",
"ioctl",
"access",
"pipe",
"select",
"sched_yield",
"mremap",
"mincore",
"madvise",
"dup",
"dup2",
"pause",
"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",
"getuid",
"getgid",
"setuid",
"setgid",
"geteuid",
"getegid",
"getpgid",
"setpgid",
"getppid",
"getpgrp",
"setsid",
"setreuid",
"setregid",
"getgroups",
"setgroups",
"setresuid",
"getresuid",
"setresgid",
"getresgid",
"getpgid",
"setfsuid",
"setfsgid",
"getsid",
"capget",
"capset",
"rt_sigpending",
"rt_sigtimedwait",
"rt_sigqueueinfo",
"sigaltstack",
"utime",
"mknod",
"uselib",
"personality",
"ustat",
"statfs",
"fstatfs",
"sysfs",
"getpriority",
"setpriority",
"sched_setparam",
"sched_setscheduler",
"sched_getscheduler",
"sched_getparam",
"sched_setaffinity",
"sched_getaffinity",
"set_thread_area",
"io_setup",
"io_destroy",
"io_getevents",
"io_submit",
"io_cancel",
"get_thread_area",
"epoll_create",
"epoll_ctl",
"epoll_wait",
"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",
"utimensat",
"signalfd",
"timerfd_create",
"eventfd",
"fallocate",
"timerfd_settime",
"timerfd_gettime",
"accept4",
"signalfd4",
"eventfd2",
"epoll_create1",
"dup3",
"pipe2",
"inotify_init",
"inotify_add_watch",
"inotify_rm_watch",
"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",
"kexec_load",
"pidfd_send_signal",
"io_uring_setup",
"io_uring_enter",
"io_uring_register",
"open_tree",
"move_mount",
"fsopen",
"fsconfig",
"statmount",
"fsmount",
"make_bad_inode",
"add_to_pagecache_lru",
"take_page_beginner",
"grab_cache_page_write_begin",
"sync_file_range",
"sync_file_range2",
"vmsplice",
"splice",
"tee",
"getxattr",
"lgetxattr",
"fgetxattr",
"listxattr",
"llistxattr",
"flistxattr",
"removexattr",
"lremovexattr",
"fremovexattr",
"setxattr",
"lsetxattr",
"fsetxattr",
"mbind",
"get_mempolicy",
"set_mempolicy",
"mq_open",
"mq_unlink",
"mq_timedsend",
"mq_timedreceive",
"mq_notify",
"mq_getsetattr",
"clock_getcpuclockid",
"alarm",
"getitimer",
"setitimer",
"timer_create",
"timer_gettime",
"timer_getoverrun",
"timer_settime",
"timer_delete",
"clock_settime",
"clock_gettime",
"clock_getres",
"clock_nanosleep",
"exit",
"kill",
"tkill",
"tgkill",
"exit_group",
"set_tid_address",
"futex",
"set_robust_list",
"get_robust_list",
"rt_sigqueueinfo",
"rt_sigtimedwait",
"timer_create",
"timer_settime",
"timer_gettime",
"timer_getoverrun",
"timer_delete",
"clock_settime",
"clock_gettime",
"clock_getres",
"clock_nanosleep",
"adjtimex",
"settimeofday",
"stime",
"time",
"vfork",
"wait4",
"waitid",
"setpgid",
"getpgid",
"setsid",
"setreuid",
"setregid",
"setresuid",
"getresuid",
"setresgid",
"getresgid",
"setgroups",
"getgroups",
"setfsuid",
"setfsgid",
"times",
"getuid",
"getgid",
"setuid",
"setgid",
"seteuid",
"setegid",
"getppid",
"getpgrp",
"setsid",
"sethostname",
"setdomainname",
"getrlimit",
"setrlimit",
"getrusage",
"umask",
"chroot",
"sync",
"acct",
"settimeofday",
"mount",
"umount2",
"swapon",
"swapoff",
"reboot",
"setpriority",
"getpriority",
"sched_setparam",
"sched_setscheduler",
"sched_getscheduler",
"sched_getparam",
"sched_setaffinity",
"sched_getaffinity",
"sched_setattr",
"sched_getattr",
"set_thread_area",
"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_setattr",
"io_setup",
"io_destroy",
"io_getevents",
"io_submit",
"io_cancel",
"set_tid_address",
"restart_syscall",
"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",
"utimensat",
"signalfd",
"timerfd_create",
"eventfd",
"fallocate",
"timerfd_settime",
"timerfd_gettime",
"accept4",
"signalfd4",
"eventfd2",
"epoll_create1",
"dup3",
"pipe2",
"inotify_init",
"inotify_add_watch",
"inotify_rm_watch",
"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"
}
]
}
(注:上面这个 JSON 列表是 Linux 系统调用的完整白名单,如果你不想写这么长,可以直接引用 Docker 官方提供的默认配置。)
有了这个配置,你的 PHP 进程就像是一个只会说“你好”的哑巴,你不能让它说话(执行命令),它只能乖乖地用眼睛看(读取文件)。
第五章:随机文件系统 —— 最强的心跳监控
前面我们讲了只读容器和 seccomp,这已经非常强了。但是,如果黑客利用了 PHP 源码里的一个未修复的 Bug(比如缓冲区溢出),直接在内核层执行了代码,怎么办?
这时候,我们引入一个更高级的概念:随机文件系统。
什么是随机文件系统?
这就好比你的代码在开机的时候,系统会随机生成一串哈希值作为“根目录”的名字。如果你的容器重启了,或者你每次启动容器都用了不同的“钥匙”,那么黑客之前渗透进来的文件路径就全部失效了。
这就好比黑客潜入了你的房子,但你每次回家都把门锁密码换成了 123456,还把家具都搬到了不同的房间。黑客还没来得及上厕所,门就换了。
技术实现:
我们需要在 Docker 的启动脚本里做文章。
- 创建一个启动脚本
entrypoint.sh:
#!/bin/bash
# 1. 生成一个随机目录名,比如 /data_3f8a2b1c
MOUNT_POINT="/data_${RANDOM}"
# 2. 创建这个目录
mkdir -p $MOUNT_POINT
# 3. 挂载卷
# 注意:这里的 -v 参数是动态的,每次启动目录都不同
docker run -d
--name php-app
--read-only
--cap-drop ALL
--cap-add CHOWN
--cap-add DAC_OVERRIDE
--security-opt no-new-privileges
--security-opt seccomp=seccomp-profile.json
-v $MOUNT_POINT:/var/www/html
-p 80:80
my-php-image
效果:
如果黑客通过某种方式在之前的容器实例中写入了 webshell.php,当你重启容器(导致随机目录名改变)时,那个 webshell.php 就因为挂载目录变了而无法被访问,或者它根本不存在于新的文件系统中。
这种策略极大地增加了攻击者的成本,迫使攻击者必须针对每一个新的环境重新发起渗透,这对于自动化攻击脚本来说是致命的。
第六章:构建“防弹级” PHP 生态系统的代码示例
现在,让我们把这些技术整合到一个完整的 Dockerfile 和 docker-compose.yml 示例中,让你真正地感受到这种“监狱级”的安全感。
6.1 PHP 官方镜像的定制
不要直接用 php:7.4-apache 这种默认镜像,那是裸奔。我们要自己定制。
Dockerfile:
# 第一阶段:构建 PHP 环境
FROM php:7.4-fpm-alpine AS builder
# 安装必要的系统工具和扩展
RUN apk add --no-cache
libpng-dev
libzip-dev
&& docker-php-ext-configure gd --with-freetype
&& docker-php-ext-install -j$(nproc)
pdo
pdo_mysql
gd
zip
opcache
# 复制你的应用代码
COPY . /var/www/html
# 第二阶段:创建一个安全的运行环境
FROM alpine:latest
# 安装 CA 证书,防止 curl 请求证书问题
RUN apk --no-cache add ca-certificates tzdata
# 设置时区
ENV TZ=Asia/Shanghai
# 复制 PHP 配置
COPY php.ini /usr/local/etc/php/php.ini
# 创建一个非 root 用户,不要用 root 运行 PHP
RUN addgroup -g 1000 -S appgroup &&
adduser -u 1000 -S appuser -G appgroup &&
mkdir /var/www/html &&
chown -R appuser:appgroup /var/www/html
USER appuser
# 复制安全策略配置
COPY seccomp-profile.json /etc/seccomp-profile.json
# 暴露端口
EXPOSE 9000 80
# 启动命令:使用 --read-only 和挂载卷
CMD ["php-fpm", "-F", "--read-only", "/var/www/html", "--security-opt", "no-new-privileges"]
6.2 PHP 配置文件 php.ini
这是 PHP 的宪法,必须严格。
; 禁用危险的函数
disable_functions = exec,passthru,shell_exec,system,proc_open,popen,curl_exec,curl_multi_exec,parse_ini_file,show_source
; 关闭危险的全局变量污染
register_globals = Off
; 最重要的一行:限制文件访问范围
open_basedir = /var/www/html:/tmp
; 错误日志记录
display_errors = Off
log_errors = On
error_log = /var/log/php_errors.log
; 关闭 URL 包含
allow_url_include = Off
; 关闭魔术引号(PHP 7.2+ 已移除,但保留配置以防万一)
magic_quotes_gpc = Off
; 限制资源使用
memory_limit = 128M
max_execution_time = 30
max_input_time = 30
upload_max_filesize = 10M
post_max_size = 10M
; 开启 OPcache
opcache.enable = 1
opcache.enable_cli = 1
opcache.memory_consumption = 128
opcache.interned_strings_buffer = 8
opcache.max_accelerated_files = 4000
第七章:心态建设——安全是一场没有终点的长跑
说了这么多技术,我想和大家聊聊心态。
很多开发者认为,只要代码写得完美,就没人能黑。错!这是一个巨大的误区。即便你的代码写得像爱因斯坦的手稿一样优雅,如果你把代码运行在一个随时可能被篡改的裸机或者配置随意的虚拟机上,黑客只需要利用操作系统的漏洞(比如 Dirty Cow),就能在几秒钟内拿到 root 权限,然后随便改你的代码、删你的库。
我们今天构建的这套方案——容器化、只读文件系统、open_basedir 限制、Seccomp 白名单、随机挂载——它不是为了防御那些只会写简单的 SQL 注入的黑客,它是为了防御那些真正的专业选手,或者是自动化武器化的攻击脚本。
这套方案可能会带来一些“副作用”:
- 调试麻烦: 如果文件系统是只读的,你无法直接在服务器上修改代码重新上传,你需要构建新的镜像。
- 权限地狱: 如果你不小心把
CHOWN权限丢了,你的 PHP 进程可能连mkdir都做不了,导致整个服务崩溃。 - 性能损耗: Seccomp 检查、Chroot、只读挂载都会带来一点点微不足道的性能开销,但对于现代服务器来说,这就像蚊子腿上的肉,完全可以忽略不计。
但是,为了安全,这点代价算什么?
当你的竞争对手黑进了隔壁老王的服务器,勒索了他 5000 美元,而你的服务器因为开启了 open_basedir 和只读容器,黑客连 touch 命令都敲不出来的时候,那种淡定,才是大佬该有的风范。
结语
好了,今天的讲座就到这里。
我们回顾一下今天的主要内容:
- RCE 是 PHP 的噩梦,光靠 WAF 是不够的。
open_basedir是第一道防线,限制了文件读取。- 只读容器 是第二道防线,禁止了文件写入,防止持久化后门。
- Seccomp 是第三道防线,禁止了命令执行。
- 随机文件系统 是最后的底牌,增加了攻击者的成本。
记住,安全不是一种产品,而是一种思维模式。不要让你的代码在裸奔。从今天开始,给你的 PHP 代码穿上这身“防弹衣”吧。
谢谢大家,我是老王,我们下次再见!记得给你的服务器上锁,别让你的代码跑了!