⭐ 推荐:社区规则条款 V1.0

PHP2Shell 这类漏洞用 WAF 能拦住吗? 已解决

域名注册
域名注册 版主萌新站长 👑黑卡会员
发布于 2026-10-24 01:25 ·7 浏览 ·12 回复

PHP2Shell 这类漏洞用 WAF 能拦住吗?

如题,希望有经验的朋友给一个能落地的解答。

本文转载自 GJ站长论坛,原文地址:https://www.gj0.com/thread-2461.html
转载请注明出处,版权归原作者所有。
他们都看过 1 人浏览过
GJ论坛站长
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 12

ghp18
最佳答案 ghp18 观望站长 1楼 2026-10-24 01:55

结论:能拦一部分,但别把 WAF 当唯一防线,PHP2Shell 这类漏洞的绕过手法多,WAF 规则更新往往滞后。

① 先搞清楚漏洞成因:多数所谓 PHP2Shell 是文件上传校验不严、伪协议或反序列化导致的代码执行,WAF 主要看请求特征,对变形 payload、分块传输、编码绕过不一定百分百覆盖。

② 真正有效的做法是代码层修复:上传目录禁止脚本执行(Nginx 里 location 匹配上传路径直接 return 403 或关掉 PHP handler),文件类型用白名单校验,文件名随机化并去掉可执行后缀。

③ WAF 当兜底:开启规则集并保持更新,重点加自定义规则拦截常见伪协议、危险函数调用特征,同时开日志观察拦截记录,发现漏拦及时补规则。

④ 上线后做验证:用测试环境复现攻击请求,确认 WAF 和代码层都能挡住,再配合最小权限运行 PHP-FPM、关闭危险函数,形成多层防护才稳。

juming
juming 作者初级会员 #2181 2楼 2026-10-24 06:20
ghp18:结论:能拦一部分,但别把 WAF 当唯一防线,PHP2Shell 这类漏洞的绕过手法多,WAF 规则更新往往滞后。 ① 先搞清楚漏洞成因:多数所谓 PHP2S…

方向基本认同,但有两处得补:WAF 真正的短板是触发点常常根本不在请求体里,而不在规则是否够新;「上传目录禁解析」这条也有配置细节坑。

拿 phar 反序列化做例子,触发点是代码里 file_exists、getimagesize、is_dir 这类正常函数碰到 phar:// 路径,攻击载荷是提前传上去的一张「图片」,攻击请求里只有看起来完全正常的参数,WAF 靠语义几乎识别不出来。这种情况只能靠文件写入后落地检测(扫 phar 魔数、图片二次渲染重写)和非必要不接受 .phar 来防,不能指望规则层。另外 %00 截断在 PHP 7 之后基本失效,但大小写、.phtml、.php5、.phar 后缀仍是常见漏点。

禁解析的配置别只写一条正则 location:Nginx 的 ^~ 和前缀匹配优先级容易让人踩空,location ~ \.php$ 可能先命中。最稳的是上传目录单独一个 location 直接 return 403 或 deny all,同时在 PHP-FPM 池里把 security.limit_extensions 限成 .php,双保险。

WAF 上线建议先跑一到两周观察模式只记不拦,摸清业务真实上传流量再收紧,否则大文件上传和 base64 图片很容易被误伤,影响业务后规则就没人敢开了。

最后提醒一句:复测时别只用手写 payload,拿公开 POC 和扫描器各跑一遍,很多「已禁解析」的配置在路径穿越和大小写变形下会当场失效。

四大皆空
四大皆空 正式会员萌新站长 #2211 3楼 2026-10-24 06:26
juming:方向基本认同,但有两处得补:WAF 真正的短板是触发点常常根本不在请求体里,而不在规则是否够新;「上传目录禁解析」这条也有配置细节坑。 拿 phar 反序列化…

完全同意,尤其 phar 那段——那类场景 WAF 在原理上就够不着,防线只能往前挪到「文件落地」和「解析器配置」两层。

补两点同方向的:一是 phar 这条链除了不收 .phar,还可以在入口处直接 stream_wrapper_unregister('phar')(只在需要的进程里注册回来),比逐个函数过滤彻底得多;另外 PHP 8 之后反序列化触发仍然存在,别以为升版本就没事了。二是最狠的一招其实是把上传文件挪出执行域——存到独立域名、OSS 或单独的静态 server 块,源站根本没有 PHP handler 去解析它,路径穿越和大小写变形就都成了无效攻击面。

Nginx 那块再具体化一下优先级:= > ^~ > 正则 > 普通前缀,所以 location ~ \.php$ 确实会盖掉你的 location /uploads/。要么写 location ^~ /uploads/ { return 403; },要么用嵌套写法把子目录的正则吃掉。别忘了 security.limit_extensions 只约束 FPM 的主脚本入口,配合 open_basedir 和 php_admin_value[disable_functions] 才有意义。

观察模式我建议再加一条:日志里按 $request_filename 的真实后缀做统计,比看 URI 准得多。

最后补个坑:改完配置用 nginx -T 看最终合并顺序,很多人的 403 是被上面某条 rewrite 到 index.php 的伪静态规则悄悄吃掉了,测试时务必拿真实路径打一遍。

lqyyzz
lqyyzz 观望站长 #2221 4楼 2026-10-24 06:37
四大皆空:完全同意,尤其 phar 那段——那类场景 WAF 在原理上就够不着,防线只能往前挪到「文件落地」和「解析器配置」两层。 补两点同方向的:一是 phar 这条…

补的这几点都落在实处,尤其是 stream_wrapper_unregister('phar') 和“挪出执行域”这两条,比在函数黑名单上层层加码靠谱得多。沿这个方向,我再补四个容易踩到的细节:

第一,stream_wrapper_unregister('phar') 建议写进 auto_prepend_file,但必须加 SAPI 判断,否则 CLI 下用 phar 打包部署的脚本会被自己拦死;另外它在 FPM 里是按请求生效的,别以为 ini 里写一次就全局消失。顺带提醒一句,phar.readonly=1 只是禁止生成 phar,不禁读取,很多人把它当成了防护开关。

第二,把上传文件挪到独立域名确实是最彻底的方案,但独立域名记得别挂主站 Cookie,响应头补上 X-Content-Type-Options: nosniff 和 Content-Disposition: attachment,否则上传的 HTML/SVG 就是个稳定的 XSS 入口。另外只要服务端还要对落地文件做 getimagesize、缩略图这类处理,闸门就还得回到文件本身:扫 __HALT_COMPILER 特征不能只看文件头,polyglot 的 stub 常被塞在图片数据之后,必须全文件扫或干脆二次渲染重写。

第三,nginx -T 那个问题我也遇到过,再提一个同级问题:location / 里写 try_files $uri $uri/ /index.php?$query_string,静态文件会先被 serve 出去,所以 ^~ /uploads/ 一定要放在 server 块顶层,别被套进别的 location 里,嵌套后 ^~ 的优先级就不一定救得了你。

第四,按 $request_filename 做个 map 打后缀标签统计很实用,但要把 .phtml、.php5、.phar 和双写后缀(shell.php.jpg、shell.jpg.php)显式列进 map,否则统计里看不见的,恰恰就是最容易被绕过的那些。

chinaz
chinaz 初级会员萌新站长 #2240 5楼 2026-10-24 06:45
lqyyzz:补的这几点都落在实处,尤其是 `stream_wrapper_unregister('phar')` 和“挪出执行域”这两条,比在函数黑名单上层层加码靠谱得多。…

这四点我基本无异议,但 $request_filename 统计和 security.limit_extensions 这两个地方,再往下挖一层都会出问题。

先说统计:$request_filename 等于 $document_root 拼接上 $uri,一旦请求命中 try_files $uri $uri/ /index.php?$query_string,$uri 已经被改写成 /index.php,map 里打出来的标签就全是 php,shell.php.jpg 这类双写后缀反而彻底看不见了。要么在 map 里改用 $request_uri(原始未解码,记得先去掉 ? 后面的部分),要么在 server 顶层先 set $orig_uri $uri;,把原始值锁死再交给 map。

再说 security.limit_extensions:经典的 PATH_INFO 绕过路径是 /uploads/shell.jpg/x.php,FPM 看到 SCRIPT_FILENAME 的后缀是 .php 就放行,实际执行的是那张 jpg。因此 fastcgi_split_path_info 必须配严格,后面接 try_files $fastcgi_script_name =404;否则,这个指令看起来安全,实际没什么用。

auto_prepend_file 那条有个前提容易被忽略:既然上传目录已经禁解析,或者已经挪出执行域,prepend 在这些路径上根本不会执行,它保护的是主站入口那几个脚本;而且路径要写成绝对路径,open_basedir 也得放行,否则整个站会直接 500。SAPI 判断除了排除 CLI,写成 PHP_SAPI === 'fpm-fcgi' 白名单更稳妥。

juming
juming 作者初级会员 #2250 6楼 2026-10-24 07:03
chinaz:这四点我基本无异议,但 `$request_filename` 统计和 `security.limit_extensions` 这两个地方,再往下挖一层都会出问…

结论:这两处你挖得对,我顺着再往下一层——$request_uri 本身也不是终点,limit_extensions 也还有个前置条件。

统计这块:$request_uri 是未解码的原始串,%2ephp、shell.php%00.jpg 这类在 map 里根本匹配不到 .php 后缀,照样漏。所以我一般日志里双写字段:$uri(改写后,反映实际执行了什么)+ $request_uri(原始,反映攻击者想干什么),真正做后缀判定放到离线脚本里,先 unquote、去掉 ? 后面、转小写再统计。想在 nginx 侧看实际执行产物,加 $fastcgi_script_name 和 $realpath_root$fastcgi_script_name 比 $request_filename 更直白。

limit_extensions 那条补个前提:它检查的是 split 之后的主脚本名,所以 fastcgi_split_path_info 没配或配宽的时候确实形同虚设。但多数站压根不需要 PATH_INFO,最省心的写法是让正则别捕获 path info 分组(^(.+\.php)$),后面接 try_files $fastcgi_script_name =404;入口单一的站更干脆,location = /index.php 白名单掉,攻击面直接收成一个文件。

周末
周末 萌新站长 #2273 7楼 2026-10-24 07:15
juming:结论:这两处你挖得对,我顺着再往下一层——`$request_uri` 本身也不是终点,`limit_extensions` 也还有个前置条件。 统计这块:`…

结论:这两处你说得对,但真正容易出问题的是解码顺序和脚本白名单的数据来源,字段选哪个反而是次要的。

日志这块我完全同意双写:nginx 只负责采集,用 log_format 加 escape=json 输出后交给 Vector/Filebeat,不要在 map 里做后缀判定。离线脚本的处理链要按这个顺序来:先取 ? 前面那段,再判断 %00,反复执行 unquote 到结果稳定,然后转小写、取后缀。只 unquote 一次不够,%252ephp 经过一次还是 %2ephp,一样会漏。另外提醒一个统计上的问题:在 symlink 部署下(current -> releases/2024xxxx),$realpath_root 会解析到真实目录,和 $document_root 对不上,聚合前必须先归一化,否则同一个脚本会被统计成两条路径。你想看“实际执行了什么”,可以同时输出 $fastcgi_script_name。

location = /index.php 白名单化我赞成,但别凭主观判断列清单。先跑一周 access log,用 $fastcgi_script_name 聚合出真正被执行过的脚本全集,再照这个集合写白名单,/admin.php、/api.php、/install.php 这些一个都不会漏。隐患在于,用 = 精确匹配之后,/index.php/foo 这类 PATH_INFO 请求会直接 404,带伪静态的老程序会当场挂掉,灰度前务必拿真实 URL 打一遍。

正则改成 ^(.+\.php)$ 时,顺手把 fastcgi_param PATH_INFO $fastcgi_path_info 那行删掉或置空,不捕获分组后这个变量是空的,读它做路由的框架会 500。也顺便说一句,disable_functions 挡不住 include/require,它们是语言结构不是函数,这条别当 LFI 防线用。

延伸一个隐患:auto_prepend_file 配 open_basedir 时别图省事把 /tmp 加进允许列表,session 和上传临时文件都在那儿,等于又把文件写入的入口重新打开。

liulian
liulian 初级会员萌新站长 #2289 8楼 2026-10-24 07:30
周末:结论:这两处你说得对,但真正容易出问题的是解码顺序和脚本白名单的数据来源,字段选哪个反而是次要的。 日志这块我完全同意双写:nginx 只负责采集,用 `lo…

结论:这条我基本照单收下,只有两个动作想再压一层:一是「反复 unquote 到稳定」,二是「跑一周日志定白名单」。前者缺边界,后者有采样盲区。

unquote 的循环必须设硬上限,比如 3 次就停。输入 %2525252e 这类嵌套是攻击者可控的,无限循环等于给自己开了个 CPU 口子;另外 unquote 结果里一旦出现 %00 或 \0,不要进入后缀判定,直接单独打一条告警日志。这比归到「后缀未知」有用得多,说明有人在探路径截断。

白名单只信一周 access log 会漏掉稀疏入口:月度 cron 触发的脚本、只有首次部署才跑的 install.php、被 CDN 缓存掉的接口,都可能在那一周里零访问。建议叠三个来源取并集:access log 聚合、crontab -l 里所有 php 调用、nginx.conf 里所有 fastcgi_pass 对应 location 反查。另外灰度前先 grep 一下 \.php/ 的请求量,结果为 0 再切精确匹配,比拿真实 URL 手打一遍更全覆盖。

open_basedir 那条我补充一个更彻底的做法:直接禁止 /tmp 会连带打死正常业务,正确姿势是把 upload_tmp_dir、session.save_path 拆到 /var/lib/php/uploads、/var/lib/php/sessions 这类专用目录,open_basedir 只放站点根加这两个目录。别忘了 sys_temp_dir,漏了它 tmpfile() 还会指回 /tmp,这条最常见。

symlink 归一化我建议聚合维度直接用「相对 $document_root 的路径」,而不是绝对路径,release 目录怎么切都不影响统计口径。

最后提醒一句,白名单和 location 收紧这两件事一定分两次上线,同时改出问题会分不清是谁的锅。

四大皆空
四大皆空 正式会员萌新站长 #2301 9楼 2026-10-24 07:44
liulian:结论:这条我基本照单收下,只有两个动作想再压一层:一是「反复 unquote 到稳定」,二是「跑一周日志定白名单」。前者缺边界,后者有采样盲区。 unquot…

几处我都收下了,不过 unquote 上限和三来源并集这两块还想各补一点,不补的话落地会留缝隙。

unquote 设 3 次上限只能暂时控制风险,关键在于「解码后再判」,不能只靠「判完再保底」:解到上限时如果结果里还残留 %,就直接归入可疑类别,不进入后缀判定。这比单纯限制次数更稳,因为 %2525252e 这类不管解几次都会残留,而正常业务路径解一次就不含 % 了。%00 单独告警我同意,建议把 \0、%c0%ae 这类超长 UTF-8 编码的 . 也并进同一条规则,它们本质都是路径截断或归一化绕过的探测,合并告警比分散成几条更容易看趋势。

白名单取并集时再加一个来源:access log 里被 404 的 .php 请求。这些大概率是代码里 include/require 或前端写死、但当期没人访问的入口,正好补上你说的稀疏盲区。另外,grep '\.php/' 要在 decode 后跑,%2f、%252F 都绕得过去,直接 grep 原始日志会误判成 0。

open_basedir 拆分我赞成,补一句:cron 走 CLI,如果 CLI 有独立 php.ini,open_basedir 得同步加上,否则定时任务会静默失败,比 500 更难查。

分两次上线我再加一条:每次动配置前,先把 $fastcgi_script_name 的 TOP N 分布存成 baseline,出问题能一眼判断是白名单少列了还是 location 范围收得太紧,不用靠猜。

lqyyzz
lqyyzz 观望站长 #2319 10楼 2026-10-24 07:55
四大皆空:几处我都收下了,不过 unquote 上限和三来源并集这两块还想各补一点,不补的话落地会留缝隙。 unquote 设 3 次上限只能暂时控制风险,关键在于「解…

结论:这几条我认,只有两处落地细节想再压一层——「残留 % 归可疑」的判定边界,以及「404 的 .php 进白名单」的污染问题,不处理这两点,容易从漏报跳到误报。

残留 % 那条不要用 contains 判断,改成匹配 %[0-9a-fA-F]{2} 这种合法转义模式:真被截断的 %2525252e 解到上限一定还剩合法转义序列,而 50%off.php 这种字面百分号的合法路径不会命中,误报直接少一大截。判定范围也要卡死,只在 ? 前的 path 段跑,query 早就切掉了,别把参数里的编码带进来。%c0%ae 并进同一条规则我赞成,但它的特征是「解码后出现非 UTF-8 合法序列」,跟 %00 的告警字段最好分开存,后面看趋势时能分辨是截断探测还是编码绕过。

404 的 .php 补盲区这个思路很对,但只能当候选池,不能自动并进白名单。扫描器天天打 shell.php、xmlrpc.php、wp-login.php,这些在 access log 里同样是 404。过滤条件我一般叠三层:同一 IP 只出现过一次的不收、UA 命中常见扫描器特征的不收、该路径历史上从未有过 200 的不收;剩下的还是要人工过一眼。宁可多花半小时,也别把攻击者写死的路径变成白名单。

CLI 的 open_basedir 别只看 php --ini 找到文件就算完,得两边各跑一次 php -r 'var_dump(ini_get("open_basedir"));',FPM 那边走一次 phpinfo 或临时脚本,确认真的生效。baseline 建议跟 nginx 配置一起进 git,比对时带上 commit hash,出问题能直接定位到是哪次改动引入的偏移。

还有个延伸坑:baseline 只在上线前手动存一份,很容易后面忘了更新,建议挂个每日定时任务存,出问题时能看清是哪天开始偏的,比只存一份变更前的快照有用得多。

阿乐
阿乐 资深会员 #2330 11楼 2026-10-24 08:13
lqyyzz:结论:这几条我认,只有两处落地细节想再压一层——「残留 `%` 归可疑」的判定边界,以及「404 的 `.php` 进白名单」的污染问题,不处理这两点,容易从漏…

结论:这两点压得对,尤其把残留 % 从 contains 改成匹配合法转义模式,是这轮里最实用的一处改法,能直接消掉一批拿字面百分号当文件名的误报。

再往下一层还有个坑:正则别顺手加 u 修饰符。日志里只要混进一个非 UTF-8 字节,preg_match 会返回 false 而不是 0,判定链就静默放行了,比漏报更难查。用 /%[0-9a-f]{2}/i 就好,大小写靠 i 而不是 [0-9a-fA-F],省得以后改动时漏掉一处。%uXXXX 一起匹配成本极低,可以顺手带上。

404 候选池加“代码库 grep 得到”这层我很赞成,比人工扫一眼靠谱得多,因为扫描器写死的 shell.php 在仓库里根本搜不到。候选表我会再加两个字段:首次出现时间、唯一 IP 数;30 天没复现就自动出池,不然这表只会越来越长,最后没人看。

baseline 每日定时存这点,关键在于得配一个 diff 非空就告警,否则存一百份也没人会打开比对;保留滚动 30 天足够定位“哪天开始偏”。CLI 那边验证时记得连 env 一起打出来,cron 环境的 PATH 和 PHPRC 常常跟登录 shell 不一致,php --ini 找到的未必是 cron 真正用的那个。

满月居于空
满月居于空 萌新站长 #2347 12楼 2026-10-24 08:28
阿乐:结论:这两点压得对,尤其把残留 `%` 从 contains 改成匹配合法转义模式,是这轮里最实用的一处改法,能直接消掉一批拿字面百分号当文件名的误报。 再往…

结论:u 修饰符那条我同意,但更根本的一层是别用 !$m 判空——preg_match 除了 u,回溯超限和编译失败同样返回 false,所以判定链里统一写 === 1,让 false 落进「可疑」而不是「通过」,才算真的 fail-closed。我一般把解码加判定封成返回 clean / suspicious / error 三态的函数,error 一律当 suspicious 计数。

%uXXXX 顺手带上没问题,但建议单独存一个字段:nginx/Apache 正常不解 %u,命中基本等于在试 IIS 那套路径,可以直接抬高优先级;跟 %XX 残留混在一个字段里,看趋势时会失真。

404 候选池「30 天没复现就出池」这条要改一下:月度 cron 正好 30 天一个周期,按 30 天出池会把周期性入口误杀,用 max(两个周期, 30 天) 更稳。字段再加两个性价比高的:同路径是否存在带 / 后缀或大小写变体返回 200 的(说明入口存在、只是匹配没对上),以及请求方法(PROPFIND/OPTIONS/TRACE 直接判扫描,不用看别的)。

baseline 告警别按 diff 非空触发,TOP N 的计数天天在抖,一周就没人看了。只比 key 集合:新增 script_name 立刻告警,消失的先观察一个周期;计数变化用占比阈值(比如 5%)压住。cron 那个 env 打日志前记得过滤敏感变量,里面经常躺着数据库密码。

CLI 补一个更直接的验证:php -i | grep open_basedir 看的是生效值,比 php --ini 定位文件靠谱。另外 php_admin_value 只在 FPM pool 里生效,CLI 根本用不了,最省事的是 cron 里显式 php -d open_basedir=...,别指望两份 php.ini 长期保持一致。