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)。

配置

bash
# 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 状态码

400599 范围内的任意值(默认 404)。可与 ERROR_PAGES_DIR 搭配,返回自定义的 HTML 正文:

bash
PHP_DENY_PATHS="/uploads/**" PHP_DENY_FALLBACK="403" ERROR_PAGES_DIR="/var/www/errors" # serves errors/403.html

PHP 脚本

一个以 / 开头、指向 DOCUMENT_ROOT 内某个回退脚本的 URI 路径:

bash
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'], '/'),使两者形式一致。

蜜罐示例:

/_security/denied.php
<?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,将扫描探测器重定向到黑洞),而不必受限于单一的静态状态码。

无存在性预言机

StatusScript 两种回退都会在不触碰文件系统的情况下返回。对 /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 日志:

text
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 变量作为已弃用的别名被接受,并会发出一条启动警告:

text
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 会被报告为已忽略。两者的值不会合并。

另见

  • 路由 —— 路由模式与路径安全
  • 错误页面 —— 状态码回退响应的自定义 HTML 正文
  • 配置参考 —— 完整的环境变量列表
  • 指标 —— oxphp_php_deny_total 及相关指标