PHP被黑,十有八九是函数权限没控好,或者目录没关住。很多运维把重心放在WAF和系统防火墙,却忽略了PHP自身的执行边界。攻击者拿到一个webshell,如果PHP本身没有做任何限制,他就能执行系统命令、读写任意目录、甚至横向穿透到同一台服务器的其他站点。解决这个问题的核心手段,就是禁用危险函数和配置open_basedir。

先说危险函数。PHP里有一批函数可以直接与操作系统交互,比如exec、system、passthru、shell_exec、popen、proc_open。这些函数允许PHP脚本调用系统级命令,一旦被恶意利用,攻击者就能反弹shell、下载木马、提权。很多开发者在功能实现时图方便,用了这些函数,但线上环境如果不做限制,等于给攻击者留了后门。

哪些函数必须禁用

不同业务场景对函数的需求不一样,但有一批函数在任何生产环境都不应该开放。下面这份清单可以直接拿去用:

exec, system, passthru, shell_exec, popen, proc_open, pcntl_exec, eval, assert, create_function, call_user_func_array, call_user_func, array_map, array_filter, array_walk, preg_replace with /e, dl, ini_set, ini_alter, set_time_limit, ini_restore, pfsockopen, fsockopen, socket_create, socket_connect, mail, openlog, syslog, readlink, symlink, popepassthru, stream_socket_server, stream_socket_client, proc_close, proc_get_status, proc_nice, proc_terminate, apache_child_terminate, posix_kill, posix_mkfifo, posix_setpgid, posix_setsid, posix_setuid, show_source, highlight_file

这里要特别注意eval和assert。很多免杀webshell的核心就是这两个函数,它们能把字符串当作PHP代码执行。哪怕你把上传目录的脚本执行权限关了,只要业务代码里存在变量覆盖或反序列化漏洞,攻击者照样能通过这两个函数执行任意代码。create_function同样危险,它内部也是基于eval实现的。preg_replace的/e修饰符在PHP 5.5.0之后被废弃,PHP 7.0.0直接移除,但如果你的环境还在跑老版本,这个点必须堵上。

mail函数很多人觉得无害,实际上它调用了系统的sendmail,攻击者可以通过构造特殊参数,利用sendmail执行系统命令。这在CTF比赛里是经典考点,实战中同样有效。fsockopen和pfsockopen看起来只是网络函数,但攻击者可以用它们做端口扫描、内网穿透,甚至搭建代理。如果你的业务不需要PHP主动发起外部连接,这两个函数也应该禁用。

怎么禁用才彻底

很多人以为在php.ini里写一行disable_functions就完事了,但这里有几个坑。第一个坑是PHP-FPM多版本共存。如果你服务器上同时跑了PHP 5.6和PHP 7.4,每个版本的php.ini是独立的,必须逐一配置。第二个坑是有些函数在模块里,比如pcntl_exec属于pcntl扩展,如果你没装这个扩展,它自然不存在,但一旦装了就必须禁用。第三个坑是某些函数可以通过其他方式绕过,比如putenv配合LD_PRELOAD劫持共享库,或者利用ImageMagick的delegate执行命令。

针对LD_PRELOAD绕过,光禁用mail和error_log不够,因为putenv本身在很多环境下是必须开放的。解决办法是把putenv也加入禁用列表,或者使用SELinux、AppArmor这类强制访问控制系统,限制PHP进程的系统调用。更彻底的做法是编译PHP时直接去掉不需要的扩展,从源头减少危险函数的数量。

配置示例:

; php.ini
disable_functions = exec,system,passthru,shell_exec,popen,proc_open,pcntl_exec,eval,assert,create_function,call_user_func_array,call_user_func,array_map,array_filter,array_walk,dl,ini_set,ini_alter,set_time_limit,ini_restore,pfsockopen,fsockopen,socket_create,socket_connect,mail,openlog,syslog,readlink,symlink,popepassthru,stream_socket_server,stream_socket_client,proc_close,proc_get_status,proc_nice,proc_terminate,apache_child_terminate,posix_kill,posix_mkfifo,posix_setpgid,posix_setsid,posix_setuid,show_source,highlight_file,putenv

配置完后一定要重启PHP-FPM或Apache,然后用phpinfo()确认disable_functions那一栏确实生效。别只看php.ini文件,有些面板工具会覆盖配置,实际加载的可能是另一个路径的配置文件。

open_basedir:把PHP关进笼子

危险函数禁用了,但攻击者还有别的路子。比如你的站点根目录是/var/www/siteA,攻击者通过webshell读取到了/var/www/siteB的数据库配置文件,里面存着另一个站点的数据库密码。如果服务器上跑了多个站点,一个站点被攻破,其他站点全部沦陷,这就是典型的目录隔离失败。

open_basedir的作用就是限制PHP能访问的文件目录范围。设置之后,PHP脚本只能读写指定目录及其子目录下的文件,超出范围的任何文件操作都会被拒绝。这相当于给每个站点画了一个圈,PHP脚本只能在圈内活动。

open_basedir的正确配置姿势

很多人配置open_basedir时只写了一个站点根目录,结果PHP连临时文件都写不了,session都存不上。正确的做法是把必要的系统目录也加进去:

; php.ini 全局配置
open_basedir = /var/www/:/tmp/:/var/tmp/:/usr/share/php/:/usr/share/pear/

如果是按站点隔离,应该在虚拟主机配置里单独设置:

; Apache虚拟主机配置
php_admin_value open_basedir "/var/www/siteA/:/tmp/:/var/tmp/"

; Nginx + PHP-FPM配置,在pool.d的对应配置文件里
php_admin_value[open_basedir] = /var/www/siteA/:/tmp/:/var/tmp/

这里有几个要点。第一,路径末尾的斜杠不能省,它表示目录及其子目录。第二,/tmp和/var/tmp必须加,不然session文件写不进去,用户登录直接崩。第三,多个路径用冒号分隔,Windows服务器用分号。第四,如果你用了composer,vendor目录如果在open_basedir范围外,自动加载会失败,所以要么把vendor放在站点目录内,要么把vendor所在路径也加入open_basedir。

有些框架的缓存目录、日志目录可能放在/var/www之外,比如/var/cache或/var/log,这些路径也要加进去。上线前先在测试环境跑一遍全功能测试,观察PHP错误日志里有没有open_basedir restriction in effect的警告。

upload_tmp_dir也要管

文件上传是另一个重灾区。攻击者上传webshell后,文件首先存在临时目录,然后通过move_uploaded_file移到目标位置。如果临时目录在open_basedir范围外,move操作会失败,但临时文件本身已经落在服务器上了。更危险的是,有些攻击手法利用临时文件包含漏洞,直接在临时目录执行代码。

建议在php.ini里显式设置upload_tmp_dir,把它指向一个在open_basedir范围内的目录,并且该目录不允许直接通过URL访问。同时设置upload_max_filesize和post_max_size,限制上传文件大小,防止攻击者上传大文件耗尽磁盘空间。

upload_tmp_dir = /var/www/siteA/tmp
upload_max_filesize = 10M
post_max_size = 12M
session.save_path的安全配置

PHP的session文件默认存在/tmp,而/tmp是所有用户可读写的。如果多个站点共享同一个/tmp,攻击者可以通过session文件包含漏洞跨站读取session数据,甚至通过精心构造的session内容实现反序列化攻击。

建议每个站点设置独立的session保存路径:

session.save_path = /var/www/siteA/sessions

这个目录要放在站点根目录内,并且设置合适的权限,只允许PHP进程用户读写。同时把该目录加入open_basedir范围。

防绕过:这些坑必须知道

open_basedir不是银弹,历史上出现过多次绕过漏洞。比如利用glob://伪协议、利用Symlink软链接、利用ZipArchive解压到受限目录外。PHP官方一直在修,但老版本可能还存在未修复的绕过方式。所以保持PHP版本更新本身就是一种安全策略。

另一个容易被忽略的点是exec和system虽然被禁用了,但如果攻击者能找到其他执行命令的途径,open_basedir限制对系统命令是无效的。open_basedir只限制PHP的文件操作函数,比如fopen、file_get_contents、include、require,对系统命令执行的限制为零。这就是为什么disable_functions和open_basedir必须配合使用,缺一不可。

还有一种情况是攻击者利用MySQL的INTO OUTFILE或者LOAD_FILE,这两个操作走的是MySQL进程的权限,不受PHP的open_basedir限制。如果MySQL配置不当,攻击者可以通过SQL注入直接把webshell写到网站目录,或者读取服务器上的任意文件。解决办法是配置MySQL的secure_file_priv,限制文件读写目录,同时给MySQL进程最小化权限。

实际部署的优先级建议

如果你现在就要加固一台生产服务器,按这个顺序来:

第一步,禁用危险函数。这是成本最低、见效最快的操作,改一行配置重启就行。第二步,配置open_basedir。先在一个站点上测试,确认所有功能正常后再推广到其他站点。第三步,设置upload_tmp_dir和session.save_path,确保临时文件和session文件都落在受控目录内。第四步,检查MySQL的secure_file_priv,收紧数据库文件权限。第五步,升级PHP到最新稳定版,关闭不需要的扩展。

做完这些,至少能挡住80%以上的webshell攻击。剩下的20%靠WAF、日志监控、文件完整性校验来补。安全是分层防御,PHP自身配置是最后一道防线,也是攻击者直面的一道墙,这堵墙够硬,很多攻击到这一步就停了。