PHP 执行拒绝列表
PHP_DENY_PATHS 会阻止直接执行与所配置 glob 模式匹配的 .php 文件。它针对的是遗留 PHP 应用中反复出现的一类漏洞:攻击者将一个 PHP 文件上传到可写的公开目录(/uploads、/cache、图片缩放临时目录),再通过直接 URI 访问它,从而获得代码执行能力。
这项检查在任何磁盘 I/O 之前运行,因此无论文件是否存在于磁盘上,被拒绝的路径都会返回相同的响应。攻击者无法据此得到探测上传目录的存在性预言机。
何时生效
直接映射模式,即 URI 直接解析到磁盘上某个 .php 文件的那些模式:
| 路由模式 | 是否遵循 PHP_DENY_PATHS? |
|---|---|
传统模式(无 ENTRY_FILE) |
是 |
SPA 模式(ENTRY_FILE=index.html) |
是 —— SPA 会直接执行已存在的 .php 文件,因此拒绝列表生效 |
框架模式(ENTRY_FILE=index.php) |
否 —— 发出警告并忽略 |
工作进程模式(WORKER_MODE_ENABLED=true) |
否 —— 发出警告并忽略 |
在框架模式下,每个请求都会被重写到前端控制器,任意 .php 文件永远不会被直接执行;此时拒绝列表只会破坏那些恰好以 .php 结尾的应用路由。在工作进程模式下,每个非静态请求都会被派发给工作进程脚本,因此拒绝列表没有任何可拒绝的对象。在这两种模式中设置 PHP_DENY_PATHS 都会触发一条启动警告并禁用该检查。
拒绝列表还会覆盖那些被间接访问到的脚本:一个对 /uploads/ 的请求如果通过目录索引查找解析为 uploads/index.php,当 uploads/** 在列表中时它就会被拒绝 —— 用于匹配模式的是解析后的脚本路径,而不仅仅是请求 URI。对于此类拒绝,OXPHP_DENIED_PATH 携带的是去掉末尾斜杠的、经过净化的请求 URI(/uploads/ 会被记为 /uploads)。
配置
# Comma-separated glob patterns
PHP_DENY_PATHS="/uploads/**,/cache/**,/tmp/**"
# What to return on a match (default: 404)
PHP_DENY_FALLBACK="403"现在对 /uploads/shell.php 的请求会直接返回 403,不会触碰磁盘。而对 /uploads/image.png 的请求会正常提供 —— 拒绝列表只影响 .php 的执行,绝不影响静态文件的提供。
模式语法
模式使用 globset 语法,针对经过净化的 URI(其中的 .. 段以及百分号编码的绕过手法都已被解析掉的请求路径)进行匹配。每个模式开头的 / 是可选的 —— /uploads/** 与 uploads/** 等价。
| 模式 | 匹配 | 不匹配 |
|---|---|---|
/uploads/** |
/uploads/x.php、/uploads/a/b/c.php、/uploads/shell.php/extra |
/uploads.php、/public/uploads/x.php |
/files/*.php |
/files/x.php |
/files/sub/x.php(单个 * 不跨越 /) |
/admin/legacy.php |
/admin/legacy.php |
/admin/legacy.php/x(不覆盖 PATH_INFO —— 见下文) |
/admin/legacy.php{,/**} |
/admin/legacy.php、/admin/legacy.php/x |
/admin/other.php |
/**/wp-config.php |
/wp-config.php、/site/wp-config.php |
/wp-config.txt |
多个模式以 OR 组合 —— 只要请求匹配任意一个模式,就命中拒绝列表。
单个文件与目录
两者都可以。/uploads/** 会阻止整棵子树;/admin/legacy.php 只阻止某一个特定脚本。若要同时阻止某个遗留入口点以及对它的任何 PATH_INFO 调用(/admin/legacy.php/foo),请使用花括号形式:/admin/legacy.php{,/**}。
大小写敏感性
匹配是区分大小写的。在大小写不敏感的文件系统上(macOS 默认的 HFS+/APFS、Windows 默认的 NTFS、启用 casefold 的 ext4),对 /uploads/Shell.PHP 的请求会绕过 /uploads/**/*.php 这样的模式。在从这类文件系统提供服务时,请使用像 /uploads/** 这样宽泛的目录模式(它匹配所有扩展名),或者在写入时将上传文件名统一规范化为小写。
回退模式
PHP_DENY_FALLBACK 控制命中时返回的内容。
HTTP 状态码
400–599 范围内的任意值(默认 404)。可与 ERROR_PAGES_DIR 搭配,返回自定义的 HTML 正文:
PHP_DENY_PATHS="/uploads/**"
PHP_DENY_FALLBACK="403"
ERROR_PAGES_DIR="/var/www/errors" # serves errors/403.htmlPHP 脚本
一个以 / 开头、指向 DOCUMENT_ROOT 内某个回退脚本的 URI 路径:
PHP_DENY_PATHS="/uploads/**"
PHP_DENY_FALLBACK="/_security/denied.php"该脚本在启动时会被校验 —— 它必须存在、规范化后位于 DOCUMENT_ROOT 之内,并且自身不得匹配 PHP_DENY_PATHS(防止循环;否则启动中止)。脚本运行时会额外获得两个标识原始请求的 $_SERVER 键:
$_SERVER 键 |
值 |
|---|---|
OXPHP_DENIED_PATH |
原始的、经过净化的 URI,带前导 /(与 PATH_INFO 形式相同) |
OXPHP_DENIED_PATTERN |
命中的 glob 模式 |
OXPHP_DENIED_PATTERN 存储时不带前导 /(已按 glob 规范化),而 OXPHP_DENIED_PATH 保留请求 URI 的 /。如果你要将路径与模式进行比较,请先对路径执行 ltrim($_SERVER['OXPHP_DENIED_PATH'], '/'),使两者形式一致。
蜜罐示例:
<?php
// /_security/denied.php — runs in place of any matched .php request.
error_log(sprintf(
"PHP execution denied: path=%s pattern=%s ip=%s ua=%s",
$_SERVER['OXPHP_DENIED_PATH'] ?? '',
$_SERVER['OXPHP_DENIED_PATTERN'] ?? '',
$_SERVER['REMOTE_ADDR'] ?? '',
$_SERVER['HTTP_USER_AGENT'] ?? '-',
));
http_response_code(404);
echo "Not Found";这让你可以逐请求地决定响应(对攻击者返回 404,对已认证的管理员返回 403,将扫描探测器重定向到黑洞),而不必受限于单一的静态状态码。
无存在性预言机
Status 和 Script 两种回退都会在不触碰文件系统的情况下返回。对 /uploads/never-uploaded.php 的请求和对 /uploads/actually-on-disk.php 的请求会产生完全相同的响应 —— 没有时间差异,也没有正文差异。扫描已上传 shell 的攻击者无法利用拒绝列表来枚举哪些文件名存在。
解析路径这一道筛查是唯一的例外:它必然在路由解析之后运行,因此它的拒绝是依赖于文件是否存在的。只有当 uploads/index.php 确实存在于磁盘上时,/uploads/ 才会被拒绝;同样,对于像 /uploads/*.php 这样的单星号模式,只有当 uploads/shell.php 存在时,PATH_INFO 请求 /uploads/shell.php/x 才会被拒绝(完整 URI 并不匹配该模式 —— 匹配的是解析后的脚本)。而直接 URI 这道筛查 —— 也就是攻击者用来探测的那道 —— 始终不存在预言机。
可观测性
| 指标 | 描述 |
|---|---|
oxphp_php_deny_total |
每次请求被拒绝时递增的计数器 |
每次拒绝还会产生一条 tracing::info 日志:
PHP execution denied by PHP_DENY_PATHS path=uploads/shell.php pattern=uploads/**访问日志记录的是最终产生的状态码(PHP_DENY_FALLBACK 的值,或回退脚本的 http_response_code())—— 在访问日志层面,被拒绝的请求与正常请求无法区分。请与该指标或结构化日志交叉比对,以归因流量激增的来源。
性能
匹配是一次 globset::GlobSet 查找 —— 通常是对 URI 字节的单次 SIMD 扫描。命中还会绕过路由缓存(被拒绝的 URI 来自攻击者可控的喷洒式攻击,基数实际上无上限;缓存它们会让攻击者把合法条目从 LRU 中挤出)。预热后,命中路径和未命中路径都不会产生分配。
局限性
这项功能不会做的事:
- 字面文件模式的 PATH_INFO 绕过。
/admin/legacy.php模式不匹配/admin/legacy.php/extra。请使用/admin/legacy.php{,/**}来同时覆盖两者,或改用目录模式。 - 区分大小写的匹配(见上文 大小写敏感性)。
- 不支持正则。 模式只支持 glob —— 锚定式,带
*/**/?/[abc]/{a,b}运算符。请使用多个以逗号分隔的模式来代替(a|b)。 - 对
include/require/eval无效。 拒绝列表只管辖直接 URI 执行。一个执行include $_GET['page']的存在漏洞的脚本,仍然能从服务器可读的任何位置加载 PHP。
已弃用的别名
遗留的 PHP_DENY_DIRS 变量作为已弃用的别名被接受,并会发出一条启动警告:
WARN PHP_DENY_DIRS is deprecated, use PHP_DENY_PATHS instead — the alias will be removed in a future release当两者同时设置时,PHP_DENY_PATHS 生效,PHP_DENY_DIRS 会被报告为已忽略。两者的值不会合并。