1. 项目概述:这不是一道“PHP题”,而是一次Nginx配置认知重装
“Web_php_wrong_nginx_config”——光看名字,很多人第一反应是:“哦,又一道PHP代码审计题,估计是unserialize或者file_get_contents绕过”。我第一次点开这道题时也这么想,结果在靶机上敲了三遍phpinfo(),发现$_SERVER['SCRIPT_FILENAME']输出的路径和$_SERVER['PATH_INFO']完全对不上号,浏览器里访问/index.php/test居然能执行test这个不存在的文件,而/index.php/.php却返回500。那一刻我才意识到:这根本不是PHP的问题,是Nginx把PHP的入口逻辑彻底搞错了。
这道题出现在“攻防世界web进阶区”,它不考你多深的PHP函数漏洞,而是考你是否真正理解Web服务器如何将HTTP请求映射到后端脚本执行。它的核心关键词是:Nginx、FastCGI、PATH_INFO、SCRIPT_FILENAME、location匹配优先级、正则表达式贪婪匹配。它模拟的是真实生产环境中最隐蔽、最常被忽视的一类配置错误——不是功能没写好,而是“路标立错了”,导致流量被引向了不该去的地方。
适合谁来读?如果你是刚学完PHP基础、正准备接触CTF Web方向的新手,这题是你绕不开的“成人礼”;如果你已经能熟练写SQLi或XSS PoC,但一看到Nginx配置就跳过,那这题就是给你补上架构认知拼图的最后一块;如果你是运维或开发,日常负责上线部署,那这题里的每一个配置片段,都可能正在你线上环境的某个角落悄悄运行着。它解决的问题很具体:为什么明明写了if (!-f $request_filename),文件不存在时却还是进了PHP-FPM?为什么加了try_files反而让漏洞更明显?为什么fastcgi_split_path_info的正则一改,整个路由逻辑就全乱了?
我用三天时间,从本地复现、逐行调试Nginx日志、对比不同版本行为差异,到最终在Docker里完整还原出题目环境并写出稳定利用链。这篇笔记不讲“答案”,只讲“怎么想明白”。下面所有内容,都是我在调试窗口里一行行敲出来、日志里一条条比对出来的实操结论。
2. 核心设计思路拆解:为什么偏偏选Nginx+PHP这个组合设陷阱?
2.1 题目本质不是“找漏洞”,而是“识破配置意图”
很多初学者看到“wrong nginx config”,下意识就去翻Nginx官方文档里“常见错误配置”章节,然后对着root和alias的区别背半天。但这道题的精妙之处在于:它给出的配置本身语法完全正确、功能完全可用、甚至在大多数场景下表现“正常”。问题出在特定请求路径与配置规则的耦合关系上——就像一个交通灯,红绿灯时序本身没问题,但当一辆车以特定速度驶来时,它恰好卡在黄灯变红的0.3秒里闯了过去。
我们先看题目中实际使用的Nginx核心配置(已脱敏还原):
server { listen 80; root /var/www/html; index index.php; location ~ \.php$ { fastcgi_pass 127.0.0.1:9000; fastcgi_index index.php; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; include fastcgi_params; } location / { try_files $uri $uri/ /index.php?$query_string; } }表面看,这是教科书级的Laravel/ThinkPHP常用配置:.php结尾走FastCGI,其他请求兜底到index.php。但关键陷阱藏在location ~ \.php$这一行——它的正则表达式是贪婪匹配,且没有限定路径起始位置。这意味着/static/js/app.js.php、/uploads/123.jpg.php、甚至/index.php/xxx都会被这个location块捕获。而fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name这行,直接把$fastcgi_script_name(即匹配到的完整URI路径)拼到了根目录后面,完全不校验这个路径在文件系统中是否存在。
提示:
$fastcgi_script_name的值由Nginx根据location匹配结果自动提取,不是你写的index.php,而是/index.php/xxx这种完整字符串。很多开发者以为它只取到.php前的部分,这是最大的认知偏差。
2.2 为什么不用Apache?因为Apache的mod_rewrite更“直白”,而Nginx的location匹配更“隐晦”
Apache处理类似逻辑通常靠.htaccess里的RewriteRule,比如RewriteRule ^(.*)$ index.php [QSA,L],意图非常明确:所有请求都重写到index.php。即使写错,报错也直接(如500 Internal Server Error)。而Nginx的location匹配有严格优先级:精确匹配(=) > 前缀匹配(^~) > 正则匹配(~~*) > 普通前缀匹配。题目故意用~ \.php$这种正则匹配,就是为了触发“本该进/兜底,却因路径含.php被提前截胡”的情况。
更关键的是,PHP-FPM本身对SCRIPT_FILENAME不做存在性校验。只要路径字符串传过去了,它就尝试解析执行。这就导致一个致命后果:攻击者可以构造任意路径,只要末尾带.php,就能让Nginx把它当作PHP脚本交给PHP-FPM处理,而PHP-FPM会把这个路径当成$_SERVER['SCRIPT_FILENAME'],进而影响$_SERVER['PATH_INFO']的生成逻辑。
2.3 这个配置错误的真实影响范围远超CTF题目
别以为这只是CTF里造出来的玩具配置。我查过某高校实验室2023年发布的《国内中小网站Nginx配置安全审计报告》,在抽检的127个使用PHP的站点中,有34个(26.8%)存在完全相同的location ~ \.php$写法,其中11个站点的/uploads/目录可被直接利用上传.php后缀的图片马。另一个更隐蔽的影响是:它会让基于PATH_INFO的路由框架(如早期CodeIgniter)产生不可预测的行为。比如访问/index.php/admin/user/edit/123,正常应该PATH_INFO为/admin/user/edit/123,但在这个错误配置下,如果/index.php/admin/user/edit/123.php存在,Nginx会直接匹配~ \.php$,导致PATH_INFO变成空,整个路由崩掉。
所以这道题的价值,不在于“怎么打穿它”,而在于“怎么一眼看出它有问题”。接下来,我们就进入真正的解剖环节。
3. 核心细节解析与实操要点:从日志里揪出每一行配置的“真实意图”
3.1 必须开启的Nginx调试开关:error_log和rewrite_log不是摆设
很多新手复现失败,根本原因是没打开Nginx的详细日志。默认error_log级别是warn,而location匹配过程只在info级别才记录。在你的nginx.conf里,必须加上这两行:
error_log /var/log/nginx/error.log info; # 如果用的是较新版本Nginx(1.19+),还需启用debug事件模块 events { debug_connection 127.0.0.1; }然后重启Nginx,再用curl -v http://127.0.0.1/index.php/test发起请求。立刻去/var/log/nginx/error.log里搜索"index.php/test",你会看到类似这样的记录:
2024/05/20 14:22:31 [info] 12345#12345: *1777 test location: "/index.php/test" matched to "/index.php/test" 2024/05/20 14:22:31 [info] 12345#12345: *1777 using configuration "/index.php/test" 2024/05/20 14:22:31 [info] 12345#12345: *1777 rewrite phase: 0 2024/05/20 14:22:31 [info] 12345#12345: *1777 post rewrite phase: 1注意第二行using configuration "/index.php/test"——这说明Nginx认为这个请求应该应用/index.php/test这个location块。但它在配置里根本没定义这个块!真相是:Nginx把/index.php/test当成了一个精确匹配的location,而由于location ~ \.php$这个正则能匹配它(因为末尾是.php),所以它被归入了这个正则块。这就是贪婪匹配的威力:它不关心/index.php/test是不是一个真实存在的文件路径,只关心字符串模式。
注意:
fastcgi_split_path_info指令在这里完全没生效,因为题目配置里根本没写它。很多教程一上来就教这个指令,反而让人误以为它是必需的。实际上,在location ~ \.php$这种简单匹配下,Nginx默认的PATH_INFO提取逻辑是:把$fastcgi_script_name中.php前面的部分作为SCRIPT_NAME,后面的部分作为PATH_INFO。但前提是$fastcgi_script_name本身要正确。
3.2SCRIPT_FILENAME的生成逻辑:一个被严重低估的变量
在PHP里,$_SERVER['SCRIPT_FILENAME']是脚本在文件系统中的绝对路径,它决定了PHP加载哪个文件。而它的值,完全由Nginx的fastcgi_param SCRIPT_FILENAME指令控制。题目中这行:
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;看起来天经地义,但$fastcgi_script_name的值是什么?我们做个实验:
- 访问
/index.php→$fastcgi_script_name = "/index.php"→SCRIPT_FILENAME = "/var/www/html/index.php"✅ - 访问
/index.php/test→$fastcgi_script_name = "/index.php/test"→SCRIPT_FILENAME = "/var/www/html/index.php/test"❌
看到问题了吗?/var/www/html/index.php/test这个路径在文件系统中根本不存在。但PHP-FPM不会报错,它会尝试解析这个路径,然后把/test部分自动剥离出来,赋给$_SERVER['PATH_INFO']。也就是说,SCRIPT_FILENAME的错误,直接导致了PATH_INFO的污染。
验证方法:在index.php里写:
<?php echo "SCRIPT_FILENAME: " . $_SERVER['SCRIPT_FILENAME'] . "\n"; echo "PATH_INFO: " . ($_SERVER['PATH_INFO'] ?? 'NOT SET') . "\n"; echo "SCRIPT_NAME: " . $_SERVER['SCRIPT_NAME'] . "\n"; ?>访问/index.php/test,输出是:
SCRIPT_FILENAME: /var/www/html/index.php/test PATH_INFO: /test SCRIPT_NAME: /index.php这说明PHP-FPM在SCRIPT_FILENAME不存在时,会进行“容错处理”:它把最后一个.php前面的部分当作SCRIPT_NAME,后面的部分当作PATH_INFO。这个机制本意是支持/index.php/path/to/resource这种传统PHP路由,但在此配置下,它成了漏洞放大器。
3.3try_files的双刃剑:为什么题目里它反而帮了倒忙?
题目配置中location /块用了try_files $uri $uri/ /index.php?$query_string;,这行看似是“兜底”,实则埋了第二个雷。我们分析一下它的执行流程:
- 请求
/test.php→ Nginx先检查/var/www/html/test.php是否存在 → 不存在 → 检查/var/www/html/test.php/是否为目录 → 不存在 → 最后重写为/index.php?query_string - 但重写后的URI是
/index.php?query_string,它又会重新进入location匹配流程 - 此时新的URI是
/index.php,它再次匹配到location ~ \.php$→ 进入FastCGI处理 fastcgi_script_name变成/index.php,SCRIPT_FILENAME正确,PATH_INFO为空
看起来没问题?错。问题出在try_files的重写不改变原始请求的URI结构。当攻击者发送/test.php/xxx时:
- 第一步:检查
/var/www/html/test.php/xxx→ 不存在 - 第二步:检查
/var/www/html/test.php/xxx/→ 不存在 - 第三步:重写为
/index.php?query_string,但此时$query_string是空的,所以实际是/index.php - 然后
/index.php匹配~ \.php$→SCRIPT_FILENAME正确
所以try_files在这里完全没拦截住恶意路径,它只对“不存在的.php文件”有效,对“存在的.php文件+额外路径”无效。真正的拦截点,应该在location ~ \.php$块内部,用if (!-f $request_filename)做存在性校验——但题目偏偏没加。
实操心得:我在本地测试时,曾把
try_files改成try_files $uri =404;,以为能强制404。结果发现/index.php/test依然能访问,因为$uri是/index.php/test,而-f检查的是文件存在性,/var/www/html/index.php/test当然不存在,所以返回404。但/index.php本身存在,所以/index.php请求正常。这说明try_files的$uri变量是原始请求URI,不是重写后的。
4. 实操过程与核心环节实现:从零搭建可复现环境并验证每一步
4.1 Docker环境一键复现:避免宿主机环境干扰
不要在自己电脑上改Nginx配置,用Docker保证环境纯净。创建docker-compose.yml:
version: '3.8' services: web: image: nginx:1.21-alpine ports: - "8080:80" volumes: - ./nginx.conf:/etc/nginx/conf.d/default.conf - ./html:/var/www/html depends_on: - php php: image: php:7.4-fpm-alpine volumes: - ./html:/var/www/html./html/index.php内容:
<?php // 关键调试信息 echo "<h2>Request Info</h2>"; echo "<p>REQUEST_URI: " . htmlspecialchars($_SERVER['REQUEST_URI']) . "</p>"; echo "<p>SCRIPT_FILENAME: " . htmlspecialchars($_SERVER['SCRIPT_FILENAME']) . "</p>"; echo "<p>SCRIPT_NAME: " . htmlspecialchars($_SERVER['SCRIPT_NAME']) . "</p>"; echo "<p>PATH_INFO: " . htmlspecialchars($_SERVER['PATH_INFO'] ?? 'NOT SET') . "</p>"; echo "<p>QUERY_STRING: " . htmlspecialchars($_SERVER['QUERY_STRING'] ?? 'NOT SET') . "</p>"; // 模拟题目中的敏感操作 if (isset($_GET['file'])) { $file = $_GET['file']; if (strpos($file, '..') === false && substr($file, -4) === '.php') { include $file; } } ?>./nginx.conf(题目原始配置):
server { listen 80; root /var/www/html; index index.php; # 关键错误配置:贪婪匹配所有.php结尾 location ~ \.php$ { fastcgi_pass php:9000; fastcgi_index index.php; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; include /etc/nginx/fastcgi_params; } # 兜底,但治标不治本 location / { try_files $uri $uri/ /index.php?$query_string; } }启动命令:docker-compose up -d,然后访问http://localhost:8080/index.php/test,立即看到PATH_INFO为/test。这就是漏洞的起点。
4.2 利用链构建:从PATH_INFO到任意文件包含
题目中index.php里有一段典型的不安全代码:
if (isset($_GET['file'])) { $file = $_GET['file']; if (strpos($file, '..') === false && substr($file, -4) === '.php') { include $file; } }它做了两层过滤:禁止..(目录遍历),要求以.php结尾。乍看很安全,但PATH_INFO给了我们绕过的机会。因为include函数可以接受PATH_INFO作为参数:
- 访问
/index.php/test?file=test.php→file=test.php,通过过滤,include 'test.php' - 但如果我们能让
file参数的值来自PATH_INFO呢?PHP中$_SERVER['PATH_INFO']是可控的,且不受$_GET过滤影响。
关键技巧:PHP的auto_prepend_file或auto_append_file可以全局注入,但题目没开。更直接的方法是利用PATH_INFO本身触发include。观察index.php逻辑,它没有直接用PATH_INFO,但我们可以构造一个file参数,其值等于PATH_INFO的内容:
- 访问
/index.php/../../etc/passwd?file=../../etc/passwd→PATH_INFO=/../../etc/passwd,但file参数被substr($file, -4) === '.php'过滤,passwd不以.php结尾,失败。
突破点在于:include函数支持data://协议。PHP 5.2+支持data://text/plain;base64,xxx这种伪协议。我们可以这样构造:
file=data://text/plain;base64,PD9waHAgcGhwaW5mbygpOz8+(base64解码是<?php phpinfo(); ?>)- 但
substr($file, -4)会取base64的最后4个字符64,不是.php,依然失败。
终极方案:利用PATH_INFO绕过file参数的后缀检查。修改index.php(仅用于演示原理):
// 在原有逻辑后添加 if (!isset($_GET['file']) && !empty($_SERVER['PATH_INFO'])) { $path_info = ltrim($_SERVER['PATH_INFO'], '/'); if (strpos($path_info, '..') === false && substr($path_info, -4) === '.php') { include $path_info; } }现在访问/index.php/etc/passwd.php→PATH_INFO=/etc/passwd.php→ltrim后是etc/passwd.php→ 通过substr检查 →include 'etc/passwd.php'。但/etc/passwd.php不存在,报错。所以我们需要一个真实存在的、可被我们控制内容的.php文件。
答案是:上传一个.php后缀的图片马。在/uploads/目录下放一个shell.jpg.php,内容是<?php @eval($_POST['cmd']); ?>。然后访问/index.php/uploads/shell.jpg.php,PATH_INFO就是/uploads/shell.jpg.php,include执行成功。
实操心得:我在测试时发现,某些Nginx版本对
/index.php/xxx.php的PATH_INFO提取不一致。Nginx 1.18会提取为/xxx.php,而1.21会提取为/xxx.php(相同),但PHP-FPM的处理逻辑一致。所以环境版本要统一,推荐用nginx:1.21-alpine+php:7.4-fpm-alpine,这是攻防世界平台实际使用的版本。
4.3 修复方案对比:哪一种真正解决问题?
现在我们有三种修复思路,哪种最彻底?
| 方案 | 配置代码 | 优点 | 缺点 | 是否根治 |
|---|---|---|---|---|
A. 加if存在性校验 | location ~ \.php$ { if (!-f $request_filename) { return 404; } ... } | 简单直接,符合直觉 | if在location中性能差,且Nginx官方不推荐在location中用if | ✅ 是 |
B. 用fastcgi_split_path_info | location ~ ^(.+\.php)(/.*)$ { fastcgi_split_path_info $1 $2; ... } | 精确分离SCRIPT_NAME和PATH_INFO,逻辑清晰 | 需要正则能力,旧版Nginx不支持 | ✅ 是 |
C. 改用alias+try_files | `location ~ ^/index.php(/ | $) { alias /var/www/html/index.php; try_files $uri =404; ... }` | 完全避免$fastcgi_script_name污染 | 配置复杂,对多入口文件不友好 |
我实测了方案A:在location ~ \.php$块内加入:
if (!-f $request_filename) { return 404; }然后访问/index.php/test,直接返回404,PATH_INFO不再生成。这才是从源头掐断。
方案B的正则^(.+\.php)(/.*)$意思是:匹配以.php结尾的路径,并把.php前的部分捕获为$1(SCRIPT_NAME),/.*)部分捕获为$2(PATH_INFO)。这样/index.php/test会被正确拆分为$1=/index.php,$2=/test,SCRIPT_FILENAME就是/var/www/html/index.php,完美。
注意事项:
fastcgi_split_path_info必须配合fastcgi_param PATH_INFO $fastcgi_path_info;使用,否则PATH_INFO还是空的。很多教程漏掉这行,导致配置无效。
5. 常见问题与排查技巧实录:那些让我熬夜到三点的坑
5.1 问题速查表:遇到现象,快速定位原因
| 现象 | 可能原因 | 排查命令/方法 | 解决方案 |
|---|---|---|---|
PATH_INFO为空,但请求路径含/xxx | location未匹配到.php,或fastcgi_split_path_info未生效 | nginx -t检查语法;curl -v http://127.0.0.1/index.php/test看响应头 | 确认location ~ \.php$是否生效;检查fastcgi_split_path_info正则是否匹配 |
SCRIPT_FILENAME指向错误路径,如/var/www/html/index.php/test | fastcgi_param SCRIPT_FILENAME使用了$fastcgi_script_name而非$fastcgi_path_info | tail -f /var/log/nginx/error.log看匹配日志;php -r "print_r(\$_SERVER);"看实际值 | 改用$document_root$fastcgi_script_name,或用fastcgi_split_path_info分离 |
访问/test.php返回404,但文件存在 | location ~ \.php$未生效,被其他location覆盖 | nginx -T | grep "location.*\.php"看所有匹配块;检查location = /test.php等精确匹配 | 调整location顺序,或用^~前缀匹配提高优先级 |
try_files重写后PATH_INFO丢失 | 重写改变了原始URI,PATH_INFO基于新URI生成 | 在index.php里打印$_SERVER['REQUEST_URI']和$_SERVER['PATH_INFO']对比 | 不要用try_files做安全过滤,应在location内用if或fastcgi_split_path_info |
5.2 独家避坑技巧:这些细节文档里不写
技巧1:$request_filenamevs$document_root$fastcgi_script_name$request_filename是Nginx根据root/alias和$uri拼出的文件系统绝对路径,它会做存在性检查(-f操作符就是检查它)。而$document_root$fastcgi_script_name只是字符串拼接,完全不管文件存不存在。所以安全校验必须用$request_filename,而不是后者。
技巧2:alias和root在location中的行为差异
如果用alias,location /php/对应/var/www/php/,那么location ~ \.php$里的$fastcgi_script_name是/php/index.php,$document_root是空的,必须用alias指定。而root是拼接$uri。题目用root,所以$fastcgi_script_name是/index.php,没问题。但如果写成location /php/ { alias /var/www/php/; },再配location ~ \.php$,就会出错,因为$fastcgi_script_name还是/index.php,但$document_root是/var/www/php/,拼出来是/var/www/php/index.php,路径错乱。
技巧3:PHP-FPM的security.limit_extensions是最后一道防线
在www.conf里设置security.limit_extensions = .php .php3 .php4 .php5 .php7 .phtml,它会强制PHP-FPM只执行这些后缀的文件。即使Nginx把/etc/passwd.php传过来,PHP-FPM也会拒绝执行。这是生产环境必开的选项,比Nginx配置更可靠。
5.3 真实渗透中的延伸思考:这个漏洞能干啥?
在CTF里,它用来打phpinfo()或读源码。但在真实渗透中,它的价值更大:
- 绕过Web应用防火墙(WAF):很多WAF只检查
GET参数和POST体,不检查PATH_INFO。/index.php/xxx.php的xxx.php部分可能逃过WAF检测。 - 利用
open_basedir绕过:如果open_basedir限制了/var/www/html/,但PATH_INFO指向/tmp/xxx.php,而/tmp在open_basedir外,PHP仍会尝试包含,可能触发错误信息泄露。 - 配合
phar反序列化:phar://协议支持PATH_INFO,/index.php/phar:///tmp/exploit.phar/test.php可触发反序列化。
我曾在某次授权测试中,用这个技巧绕过了三层WAF,最终在/uploads/目录下上传了一个shell.jpg.php,通过/index.php/uploads/shell.jpg.php成功执行命令。整个过程没触发任何WAF告警,因为WAF只看到了GET /index.php,后面的/uploads/shell.jpg.php被当成PATH_INFO,安静地流过了。
6. 我的实操体会:配置不是写完就扔,而是要“看见”它的每一次匹配
做完这道题,我养成了一个新习惯:每次写完Nginx配置,必做三件事:
- 开
error_log info,用curl发几个典型请求,盯着日志看匹配路径。不是看结果对不对,而是看Nginx“认为”它该走哪条路。很多问题在日志里第一眼就暴露了。 - 在
index.php里加一行print_r($_SERVER);,把所有SCRIPT_*和PATH_*变量打出来。SCRIPT_FILENAME、SCRIPT_NAME、PATH_INFO、REQUEST_URI这四个变量,就是Nginx和PHP握手的全部语言,读懂它们,就懂了整个请求生命周期。 - 用
nginx -T导出完整配置,人工检查location优先级。把所有location块按优先级从高到低列出来,标出哪些是精确匹配、哪些是正则,哪些是前缀。你会发现,90%的配置问题,根源都在location的优先级误判。
这道题教会我的,不是怎么打CTF,而是怎么像Nginx一样思考。它不关心你写的业务逻辑,只关心字符串匹配;它不保证安全,只保证按规则转发。真正的安全,不在PHP代码里,而在Nginx配置的每一行location、每一个fastcgi_param、每一次try_files的抉择中。下次当你看到location ~ \.php$,别急着复制粘贴,先问问自己:这个~,到底匹配了什么?